Skill: catrefuse/cocoloop-skill-factory (version 0.3.5)

--- name: cocoloop-skill-factory description: 用于创建或升级多平台 Agent Skill 的 Meta Skill。适用于用户想把模糊想法收敛成可生成最终 Skill 的稳定方案、需要结合 Cocoloop 与 ClawHub 搜索参考、选择平台模板、组织原子能力、补齐脚本化规划，并明确平台兼容与发布边界时。 version: 0.3.5 author: tanshow --- # CocoLoop Skill Factory ## Overview `cocoloop-skill-factory` 是一个面向 `codex`、`claude code`、`openclaw`、`copaw`、`molili`、`hermes agent` 的 Meta Skill。 它负责把用户的想法推进成一份稳定 spec，并把构建方向、模板选择、原子能力、脚本化策略和平台兼容边界整理清楚，最终服务于 Skill 生成与交付。 当用户出现这些诉求时使用本 Skill： - 想从零创建一个新 Skill - 想升级、改造、移植一个已有 Skill - 想先找现成 Skill，再判断复用、改造还是新做 - 想把平台差异、脚本化能力、模板选择和 benchmark 规划统一起来 ## Factory Rules 整个流程都围绕这几条规则执行： 1. 先判定任务域，再继续调研平台、依赖和执行面。 2. 先形成 spec，再进入构建与交付判断。 3. 调研和设计都保持双钻节奏，先发散，再收敛。 4. **分步询问：对话每次只推进一个关键问题，严禁一次性列出所有问题等待用户回答。必须等用户回答后再问下一个问题。** 5. **问题预算：进入调研后要先规划整轮交互的问题预算，默认总问题数不得超过 10 个。能用默认值、环境检测、已有上下文或确认题解决的，不再追加开放式追问。** 6. **环境 gate：在目标运行环境没有确认前，不得开始写 Skill 正文、模板、脚手架、实现步骤或构建命令。必须先拿到环境检测结果，再确认“当前环境是否就是目标环境”；如果不是，要同时写清当前环境和目标环境。** 7. `cocoloop`、`clawhub` 与 `github` 搜索在正常环境下默认进入流程；通用社区检索按需补充；不可用时允许降级，但要记录缺口。 8. 平台兼容声明必须以公开标准或已核实的本地协议为依据，不能凭经验口头承诺。 9. 推荐外部方案时，要同时给出接入方式、依赖门槛、风险和替代路径。 10. `benchmark` 是可选阶段，只在适合比较的任务里进入，并且默认按任务域判断是否适合进入。 ## Workflow 命令运行约定： - 如果当前目录是 `cocoloop-skill-factory/`，使用 `python3 utils/cli/<script>.py ...` - 如果当前目录是工作区根目录，使用 `python3 cocoloop-skill-factory/utils/cli/<script>.py ...` - CLI 文件带有 shebang，也可以直接执行，但默认优先推荐 `python3 ...` 的写法 关键 CLI： - `detect-environment.py` - `search-registry.py` - `reference-skill.py` 当前 CLI 边界： - `detect-environment.py` 用于环境检测 - `search-registry.py` 用于统一承载 `cocoloop`、`clawhub` 与 `github` 搜索 - `reference-skill.py` 用于把本地或 GitHub 候选 Skill 拉取到证据目录，并生成 JSON / Markdown 分析摘要 - 当前版本不提供完整的自动生成与发布 CLI，但必须在文档层明确生成、校验和发布所缺的环节 其中 `search-registry.py` 是对 PRD 中 `cocoloop-search`、`clawhub-search` 与 GitHub 检索的合并实现，只覆盖搜索能力本身，不新增额外流程能力。 其中 `reference-skill.py` 只负责候选方案证据化和目录分析，不替代设计判断。 ### Step 1: Initialize 先快速建立当前任务边界。 执行动作： 1. 判断用户要创建新 Skill，还是升级已有 Skill。 2. 检查当前仓库、工作区或现有文件里是否已经有相关上下文。 3. 运行 `python3 utils/cli/detect-environment.py`，获取平台、系统、Shell、浏览器与本地工具线索。 4. 先确认当前环境是否就是目标运行环境；如果不是，继续让用户明确目标平台、目标系统和关键运行前提。 5. 在环境结论没有确认前，不进入 Skill 正文、模板、脚手架或构建步骤的撰写。 6. 先判断当前需求属于哪个任务域；如果 `presets/` 里已有对应预设，立即读取。 7. 如果用户说“目标平台就是当前环境”，用检测结果提供候选线索，再请用户确认。 8. 判断当前环境里是否已有成熟 `brainstorming` 能力；如没有，再回退到 `sub-skills/brainstorm/SKILL.md`。 ### Step 2: Research 进入需求调研阶段时，阅读 `ref/research.md`。 **调研阶段先做任务域路由** - 先判定 `primary_domain` - 如果需求明显跨域，再补 `peer_domains` - 如果 `presets/` 中已有对应预设，优先按预设问题包继续追问 - 如果没有完全匹配的预设，也要先给出最接近的主域判断，再把剩余部分写入研究缺口 **调研阶段核心原则：分步询问，一次一个问题** - 每一轮对话只问一个关键问题，严禁一次性列出多个问题 - 必须得到用户回答后，才能决定并询问下一个问题 - 如果用户给出的信息涉及多个维度，拆分成连续轮次推进 - 维持对话的连续性，避免信息过载 **调研阶段核心原则：先规划问题预算，总量不超过 10 个** - 进入调研后，先根据当前任务域和缺口列出最小问题集，再开始追问 - 整轮需求调研默认总问题数不得超过 10 个，包含确认题 - 如果用户初始信息已经较完整，要主动合并或跳过冗余问题，而不是把预算问满 - 当预计会超过 10 个问题时，优先改成选项题、确认题、默认值和环境检测，不继续扩张访谈 - 在第 6 到第 8 个问题时，优先开始收口，整理已确认项、未确认项和默认假设 - 如果还有缺口但继续追问收益不高，明确记录到 `open_gaps`，再进入设计，不要无限延长调研 **调研阶段核心原则：先确认运行环境，再开始写** - 优先用环境检测拿到当前环境线索，再确认目标环境 - 如果用户说“就在当前环境里跑”，必须把当前环境和目标环境做一次显式确认 - 如果目标环境与当前环境不同，必须同时记录两者差异，不能只写当前环境 - 在环境未确认前，只允许继续做澄清，不允许开始写 Skill 正文、模板、脚手架、实现步骤或构建命令 **调研阶段核心原则：先确认实现方式，再开始写** - 必须先确认当前任务的实现方式，也就是选定 `Skill-only`、`Skill + CLI`、`Skill + API/MCP` 或 `Skill + CLI + API/MCP` - 如果主任务域已有推荐执行面，需要让用户确认是接受该默认执行面，还是切换到替代路径 - 在实现方式没有确认前，不开始写脚本方案、adapter、manifest、依赖安装步骤或构建命令 **提问交互格式：** 1. **选项类问题**：提供 3-5 个有序选项（1-5），便于用户直接回复数字 - 示例：目标平台选择、交付预期选择 - 必须标注推荐答案 2. **路径选择类问题**：提供 2-3 个实现路径 - 示例：复用现成 / 改造 / 从零设计 - 必须说明各路径的适用场景、优势和风险 - 明确标注推荐路径及理由 3. **开放式问题**：提供提示和示例，引导用户描述 - 示例：「请描述核心问题是什么？提示：从谁在什么场景下遇到什么痛点来描述」 4. **确认类问题**：汇总已确认信息，提供继续/暂停/修改选项 - 示例：「当前已确认：... 是否继续？1. ✅ 确认 2. ⏸️ 暂停 3. 📝 修改」 调研阶段必须拿到这些信息（分步采集）： - 主任务域、并列补充域，以及是否跨域 - 用户想解决的问题与使用场景 - 正式名称与展示名称 - 目标平台与运行环境 - 当前环境是否就是目标运行环境，以及两者差异 - 实现方式，也就是最终采用哪种执行面 - 目标平台对应的支持等级、公开标准来源，以及用户是否接受当前等级边界 - 正式名称是否已完成 `cocoloop` 和 `clawhub` 双源去重 - 偏好脚本语言、不可接受的脚本语言，以及运行时限制 - 依赖偏好与权限限制 - 如果涉及网页、图片、Figma 或其他视觉输出，要收集风格偏好，并判断是否需要引入风格约束型 Skill - 如果已经明显包含可视化输出，要隐式把这个判断写入 `output_profile.has_visual_output` - 如果涉及创作写作类输出，要收集受众、语气、篇幅、参考边界和禁忌表达 - 如果涉及网站自动化、登录态操作、批量发布或抓取，要先醒目声明账号、频率限制、验证码、反爬和平台规则风险，再继续调研 - 如果涉及强需求浏览器自动化，要比较可用执行面，并明确用户接受的安装与维护成本 - 交付预期 - 哪些能力应脚本化，哪些保持文档或模板表达 - 成功标准与是否需要 benchmark 视觉输出场景的默认推荐顺序： - 网页、落地页、应用 UI：优先判断是否需要 `frontend-skill` - 单张信息图、视觉说明图、传播型图卡：优先判断是否需要 `imagegen` - 用户明确要求 `Nothing` 风格：再引入 `nothing-design` - 图片生成或图片编辑：判断是否需要 `imagegen` 或 `gemini-image` - 如果最终交付物是 `.pptx`，优先判断是否需要 `slides` - 如果当前环境没有合适的风格约束 Skill，仍然要把风格偏好写入 spec，而不是跳过 视觉优先任务的强制规则： - 如果任务涉及网站视觉、视觉优先页面、信息图、视觉卡片或演示稿视觉，在进入具体设计前，必须先确认风格来源 - 风格来源只允许这四类：用户指定风格、用户提供 `DESIGN.md`、用户详细描述、从 `ref/design-md/` 本地参考库中选择 - 如果这四类输入都没有，只继续追问风格来源，不进入具体版式和视觉方案 - 如果需求进入正式视觉方案，继续把风格结论写入 `design_md`，让最终 Skill 自带 `references/design.md` - 只要任务包含任何可视化输出，统一 spec 中就必须写出 `output_profile.has_visual_output: true` - 如果目标产物属于 PPT、网页信息图、展示图、报告页等视觉叙事型产物，优先读取 [REDACTED_SECRET] 名称收口的强制规则： - 正式名称对标最终 slug，必须使用短横线连接的小写英文与数字 - 展示名称对标最终 display name，长度不得超过 20 个字符 - 在进入设计或生成前，必须完成 `cocoloop` 和 `clawhub` 双源去重 - 双源去重结果要写进 `research_gate.skill_identity` 创作写作类场景的处理原则： - 先确认写作对象、发布场景、语气、篇幅和参考边界 - 如果当前环境里有合适的写作或风格类 Skill，再进入推荐 - 如果没有现成 Skill，也要把风格约束明确写进需求结果与后续 spec 强需求浏览器自动化场景的处理原则： - 先判断任务能否被文本接口、公开 API 或轻量抓取替代，能替代时不要默认上浏览器自动化 - 如果必须上浏览器自动化，至少向用户比较 2 条方向，常用候选是 `opencli`、`agent-browser`、`playwright-interactive` - 比较时至少说明这几个维度：是否复用现有登录态、安装门槛、调试深度、稳定性、维护成本、失败后的替代路径 - 如果用户接受额外安装，且任务已落在 `OpenCLI` 已有站点命令、`opencli browser` 或适配器流程可覆盖的范围内，优先推荐 `OpenCLI` - 选择 `OpenCLI` 时，补充 `atomic-capability/browser-access/opencli-browser-bridge.md` 中的扩展安装与 `opencli doctor` 验证步骤 - 如果任务更偏本地页面验证、结构化截图、表单回归或独立浏览器流程，优先把 `agent-browser` 作为候选 - 如果任务更偏本地 Web 或 Electron 调试、持久会话 QA、反复迭代验证，再把 `playwright-interactive` 纳入候选 如果用户一开始就已经给出很清楚的 spec，可缩短调研轮数，但不能跳过收口确认。 ### Step 3: Search And Reference 当需求轮廓已经稳定，就进入搜索判断。 默认顺序： 1. 先根据主任务域和预设整理默认搜索关键词 2. 运行 `python3 utils/cli/search-registry.py --source cocoloop --query '...' --exact-slug '<slug>'` 3. 运行 `python3 utils/cli/search-registry.py --source clawhub --query '...' --exact-slug '<slug>'` 4. 运行 `python3 utils/cli/search-registry.py --source github --query '...'` 5. 对进入正式比较范围的本地或 GitHub 候选，运行 `python3 utils/cli/reference-skill.py fetch ...` 拉取并生成 `_reference-analysis.md` 6. 如仍有明显缺口，再补通用社区或网页搜索 7. 把结果整理成“直接复用 / 参考改造 / 仅供借鉴 / 放弃”四种结论 判断时至少回答这些问题： - 候选 Skill 与当前需求的重合度有多高 - 是否覆盖当前主任务域的核心高频任务 - 是否覆盖目标平台 - 是否有明显依赖门槛或安全风险 - 如果采用它，用户得到的是安装、二次设计，还是只拿它的能力结构 - 如果它属于浏览器自动化候选，当前覆盖面是否已经足够支撑任务，还是只能作为备选路径 如果搜索不可用，保留降级记录，再继续设计流程。 ### Step 4: Design 进入设计阶段时，阅读 `ref/design.md`。 设计阶段的硬规则： - 优先读取当前主任务域对应的预设，再展开方案比较 - 如果任务涉及排版、视觉设计、网站风格、信息图或演示稿视觉，先确认风格来源，再进入具体设计 - 如果任务属于更广义的视觉叙事型产物，先走 `structured-visual-storytelling` 的共享主线，再选 `ppt`、`web_infographic` 或 `showcase_graphic` adapter - 只要搜索结果里有需要深入判断的候选 Skill，就**必须**先把候选 Skill 全量拉取到本地再分析，不能只依据搜索摘要做设计决策 - 本地分析时，要完整查看 `SKILL.md`、子目录结构、脚本、参考文档、模板、依赖声明和关键资源 - 需要复用或借鉴的能力、设计要点、功能最佳实践和限制条件，必须详细写入设计文档，而不是只保留在临时分析里 - 如果当前设计在比较浏览器自动化方向，需要把 `opencli`、`agent-browser`、`playwright-interactive` 的安装方式、运行前置动作、适用边界和推荐顺序一起写进设计文档 视觉风格参考默认读取顺序： 1. 用户自己的品牌规范或 `DESIGN.md` 2. 用户明确点名的风格 3. `ref/design-md/` 本地风格参考库 4. 用户的自然语言风格描述 当前官方预设优先级： 1. IBM 2. Stripe 3. Notion 4. Framer 5. Figma 6. Nothing 7. Apple 设计阶段必须先展开两到三条路线，再帮助用户收敛。常见路线： - 直接复用现成 Skill - 基于现成 Skill 做二次设计 - 从零构建新 Skill 设计收口时，要明确这些结论： - 当前版本的目标与边界 - 目标平台集合 - 主流程结构 - 内置原子能力与外部依赖的取舍 - 平台模板选择 - benchmark 是否进入，以及为什么 ### Step 5: Construction Planning 进入构建准备时，阅读 `ref/construction.md`。 此阶段的核心动作： 1. 把研究与设计结论整理成统一 spec 和构建说明。 先形成一份结构化 `spec.yaml`，再继续整理研究摘要、设计摘要和构建计划。 如果没有 `primary_domain`、`coverage_status` 或 `open_gaps` 的收口结果，不得进入下一步。 如果任务涉及视觉排版产物，还需要继续补齐 `output_profile` 与 `design_md`。 2. 阅读 `atomic-capability/index.md`，为关键能力选择可复用模块。 3. 阅读 `presets/index.md` 和当前主任务域预设，确认默认输出、风险门槛和执行面。 4. 阅读 `utils/template/` 下的目标平台模板，明确输出骨架、元数据差异和脚本策略。 5. 调用 `sub-skills/skill-creator/SKILL.md`，把 spec 转成可执行的构建计划。 6. 如果任务适合比较验证，再阅读 `utils/benchmark.md`，只规划 benchmark 的进入条件、样本和判定标准。 7. 如果目标平台属于任意 `supported_*`，继续补平台安装、校验和发布边界；`supported_public` 可进入打包准备，`supported_authoring_only` 与 `supported_local_only` 只能停在作者规范或本地激活边界。 8. 条件满足时，先在 `factory-skill-builder/` 执行 `npm install`，再使用 `factory-skill-builder/scripts/build_skill_from_spec.cjs` 生成最小 Skill 骨架，并用平台校验脚本确认结果；生成链要把模板选择结果一并写入产物。 如果 `output_profile.has_visual_output` 为真，生成链还要继续输出 `references/design.md` 与 `references/design-md/`。 建议在这一阶段保留的文档产物： - `spec.yaml` - `brainstorming-notes.md` - `research-summary.md` - `reference-skill-analysis.md` - `design-summary.md` - `spec.md` - `build-plan.md` - `benchmark-plan.md`（仅在进入 benchmark 时） - `platform-support-notes.md`（平台声明有特殊边界时） ### Step 6: Deliver 默认交付物包括： - 一份收口后的统一 spec - 一份设计决策摘要 - 一份构建计划或构建说明 - 外部依赖的接入说明 - 一份平台兼容与发布边界说明 - 按需生成的 benchmark 计划 如果用户只想得到设计方案，不想立即落地产物，可以在 spec 和设计决策确认后结束。 ## Fast Paths ### 用户已经带着参考 Skill 来了 先检测环境，再直接做差异分析： 1. 当前参考 Skill 覆盖了哪些能力 2. 哪些地方要保留 3. 哪些地方要替换 4. 哪些平台适配要重写 ### 用户只想找现成 Skill 仍然要先补齐最小需求轮廓，再搜索。 不要在问题尚未收敛时直接把一串候选列表甩给用户。 ### 用户只想做平台迁移 优先保留原 Skill 的目标、触发方式和能力边界，再聚焦模板映射、依赖表达和目录结构变化。 ## Local Resources 按下面的顺序读取资源，避免一次加载过多内容： - `ref/research.md` 需求调研阶段的对话骨架、必填信息和阶段出口 - `ref/design.md` 方案展开、比较和收口方式 - `ref/platform-support-matrix.md` 子仓内的本地平台支持矩阵与声明边界 - `factory-skill-builder/scripts/` `factory` 内部的 `spec -> skill` 渲染、平台校验与打包入口 - `ref/construction.md` 如何把统一 spec 收口为构建计划与产物边界 - `sub-skills/brainstorm/SKILL.md` 没有外部 brainstorming 时的兜底调研子 Skill - `sub-skills/skill-creator/SKILL.md` 进入构建准备阶段时的规划子 Skill - `presets/index.md` 任务域预设目录和对应问题包、执行面建议 - `atomic-capability/index.md` 原子能力索引与组合建议 - `utils/template/spec-template.yaml` 统一结构化协议模板 - `utils/template/*.md` 平台模板、结构差异和选择条件 - `output/README.md` `output/` 目录契约和每类产物职责 - `utils/cli/*.py` 环境检测与搜索标准化 - `utils/benchmark.md` benchmark 进入条件与输出格式 ## Output Contract 产出构建计划或 Skill 方案文档时，至少检查这些项目： 1. `SKILL.md` 是否能独立说明触发场景、流程和资源读取顺序 2. 目标平台模板是否与最终目录结构一致 3. 外部依赖是否都有接入说明和降级路径 4. 当前阶段允许脚本化的动作是否已经覆盖环境检测、搜索、最小渲染与平台校验 5. `molili` 是否按独立平台处理，没有被并入 `copaw` 6. 是否已经明确目标平台对应的目录和元数据要求 7. benchmark 若被启用，是否明确了 `0 skill` 与目标 Skill 方案效果的比较对象 ## Boundaries 本 Skill 负责 spec 驱动、方案组织、参考检索和构建规划。 它不会把搜索命中当作唯一前提，也不会默认替用户跳过需求收口。 如果当前任务只适合出文档，不适合进入下一阶段实现，可以在说明原因后收口到文档阶段。

path: agents/openai.yaml
content: interface:
  display_name: "CocoLoop Skill Factory"
  short_description: "引导需求、检索参考、装配多平台 Skill 产物的工厂"
  default_prompt: "Use $cocoloop-skill-factory to turn a rough skill idea into a complete multi-platform skill package."


path: atomic-capability/browser-access/index.md
content: # 浏览器访问

## 适用场景

这个能力用于打开页面、读取页面信息、确认页面状态、完成交互、做页面验证，并在需要时为浏览器自动化任务提供选型依据。

## 输入

- 目标 URL
- 页面目标
- 是否必须交互
- 是否必须复用现有登录态
- 预期输出
- 登录、权限和安装前提

## 输出

- 页面可见信息
- 结构化提取结果
- 交互结果
- 推荐的浏览器执行方向
- 安装与验证说明

## 决策顺序

### 1. 先判断是不是必须上浏览器

- 文本接口、公开 API、导出接口或轻量抓取足够时，不上浏览器
- 只有网页能拿到目标内容，或必须确认真实页面状态时，再进入浏览器路径

### 2. 再判断是不是强需求浏览器自动化

满足任一条件，就按强需求浏览器自动化处理：

- 必须复用网站登录态
- 需要点击、输入、上传、切页、抓网络请求
- 需要截图、页面快照、可见性验证或回归检查
- 需要反复调试本地 Web 或 Electron 页面

### 3. 必须给用户比较可选方向

默认比较 `opencli`、`agent-browser`、`playwright-interactive`。比较时至少说明：

- 是否复用当前 Chrome 或 Chromium 登录态
- 安装门槛
- 调试深度
- 稳定性
- 维护成本
- 失败后的替代路径

## 路线判断

### 方向 1：`opencli`

优先级最高，但只有在覆盖条件成立时才推荐。

适合：

- 已有现成站点命令可用
- 需要复用当前 Chrome 或 Chromium 已登录状态
- 需要在真实浏览器里点、输、抓 DOM 或网络请求
- 后续可能把一次性流程收敛成适配器

优势：

- 支持现成站点命令、`opencli browser` 和适配器生成三条路径
- 复用已登录浏览器会话，适合账号态任务
- `opencli doctor` 可直接作为安装验收

代价与限制：

- 需要安装 `@jackwener/opencli`
- 需要安装 `OpenCLI Browser Bridge` 扩展
- 目标站点不在支持面时，需要继续走适配器流程

推荐规则：

- 用户接受额外安装
- 业务已被现成命令、`opencli browser` 或适配器流程覆盖
- 当前任务重视登录态复用和统一命令入口

安装说明见 [opencli-browser-bridge.md](/Users/tanshow/Developer/cocoloop-skill-factory-dev/cocoloop-skill-factory/atomic-capability/browser-access/opencli-browser-bridge.md)。

### 方向 2：`agent-browser`

适合：

- 独立浏览器自动化
- 页面截图、结构化快照、表单操作、页面回归验证
- 本地开发页或线上页面的可视化核查

优势：

- 命令面集中，快照、截图、等待、表单操作直接可用
- 安装方式清楚，支持 `npm`、`brew`、`cargo`
- 适合作为独立浏览器执行面

代价与限制：

- 首次使用需要执行 `agent-browser install`
- 更适合受控自动化流程，不以复用个人 Chrome 登录态为核心卖点
- 如果任务更偏适配器生成或多站点统一命令入口，通常不如 `OpenCLI`

### 方向 3：`playwright-interactive`

适合：

- 本地 Web 或 Electron 调试
- 持久会话 QA
- 改代码后反复 reload、复测和取证

优势：

- 保留 Playwright 会话句柄，适合深度调试
- 对本地开发和 Electron 场景更友好

代价与限制：

- 需要 `js_repl`
- 需要安装 `playwright`
- 环境准备成本明显高于前两条路径
- 不应该被当作所有浏览器自动化任务的默认选择

## 最小 SOP

### 1. 写清前置条件

- 目标 URL
- 登录状态
- 是否需要 cookie 或现有浏览器会话
- 是否接受安装浏览器扩展、CLI 或额外运行时
- 失败时的人工确认方式

### 2. 记录推荐顺序

- 能被 `OpenCLI` 覆盖且用户接受安装时，先推荐 `OpenCLI`
- 页面验证、截图、结构化自动化更强时，推荐 `agent-browser`
- 本地调试、持久会话 QA 更强时，推荐 `playwright-interactive`

### 3. 保留验证痕迹

至少保留一种：

- 提取到的结构化文本
- 截图
- 页面状态摘要
- 安装与验收记录

## 实操建议

- 对高风控页面，不把浏览器自动化当成默认成功路径
- 对抓取类任务，优先保留 API 或 fixture fallback
- 在最终 Skill 里把“浏览器验证”和“核心抓取逻辑”拆开，避免一个失败拖垮全部流程
- 对账号态任务，把登录态复用、扩展安装和本地凭据边界写清楚

## 边界

- 不把复杂登录、强风控和频繁变化的界面当成稳定前提
- 不默认可以绕过权限
- 不把浏览器当成万能抓取器，文本层能做的先文本层处理
- 不在未比较路线前直接替用户决定 `OpenCLI`、`agent-browser` 或 `playwright-interactive`

## 降级策略

- 页面打不开时，退回到网页摘要、手动输入或文档参考
- 如果交互失败，只保留可见内容和失败原因
- 如果页面变化太快，改为截图核对或人工确认
- 如果 `OpenCLI` 覆盖不足，转入 `agent-browser` 或 `playwright-interactive`
- 如果调试深度超出 `OpenCLI` 或 `agent-browser`，转入 `playwright-interactive`

## 与主流程的关系

这个能力适合在调研、设计、验证和构建检查时使用，尤其适合需要确认真实页面行为和浏览器自动化选型的场景。


path: atomic-capability/browser-access/opencli-browser-bridge.md
content: # OpenCLI Browser Bridge 安装指南

## 适用场景

当用户接受安装浏览器扩展，且浏览器自动化任务已经被 `OpenCLI` 支持面覆盖时，使用这份说明。

## 官方基线

- 仓库：`jackwener/OpenCLI`
- npm 包：`@jackwener/opencli`
- 当前核实版本：`1.7.4`

## 安装步骤

### 1. 安装 OpenCLI

```bash
npm install -g @jackwener/opencli@latest
```

安装后先确认版本：

```bash
opencli --version
```

### 2. 下载 Browser Bridge 扩展

1. 打开 OpenCLI 的 GitHub Releases 页面
2. 下载最新的 `opencli-extension-v{version}.zip`
3. 解压到本地目录

### 3. 在 Chrome 或 Chromium 里加载扩展

1. 打开 `chrome://extensions`
2. 开启右上角 `Developer mode`
3. 点击 `Load unpacked`
4. 选择刚刚解压出的扩展目录

### 4. 验证连接

```bash
opencli doctor
```

验收通过时，至少应看到这些状态：

- `Daemon: running`
- `Extension: connected`
- `Connectivity: connected`

## 使用前检查

- Chrome 或 Chromium 已打开
- 目标站点已经在浏览器里登录
- 浏览器里加载的是刚才解压后的扩展目录

## 初次使用建议

先跑这组命令确认支持面和基础连接：

```bash
opencli list
opencli doctor
opencli browser open <url>
opencli browser state
```

如果任务已经有现成命令，优先直接使用：

```bash
opencli <site> <command>
```

## 常见问题

### `doctor` 显示扩展未连接

- 确认扩展已经在 `chrome://extensions` 中启用
- 确认使用的是解压后的目录，而不是 zip 文件
- 确认当前打开的是 Chrome 或 Chromium

### 浏览器已连接但命令拿不到数据

- 确认目标站点已经登录
- 先在浏览器里手动打开目标页面，再重试
- 如果现成命令覆盖不足，改用 `opencli browser` 或适配器流程

### 覆盖不足

- 先尝试 `opencli browser`
- 再尝试 `opencli generate <url>`
- 仍不合适时，回退到 `agent-browser` 或 `playwright-interactive`


path: atomic-capability/data-parse-transform/index.md
content: # 数据解析与转换

## 适用场景

这个能力用于把结构化和半结构化信息转成后续可用的形态，比如把 JSON 变成表格，把表格变成字段清单，把 Markdown 变成结构化 spec。

## 选择条件

- 输入内容有明显结构。
- 需要从文本里抽取字段。
- 需要在格式之间做稳定转换。
- 需要把口语化信息整理成机器可读或人可读结构。

## 输入

- 原始数据
- 目标格式
- 字段规则
- 保留规则
- 缺失值策略

## 输出

- 转换后的数据
- 字段映射
- 缺失项
- 解析失败项

## 边界

- 不处理没有边界的开放式创作内容。
- 不把含糊输入伪装成精确结构。
- 不吞掉丢失字段，必要时要显式标记。

## 降级策略

- 结构不完整时，先输出半结构化草案。
- 格式不稳定时，先保留原文，再逐步抽取字段。
- 不能自动识别时，转人工确认字段。

## 与主流程的关系

这个能力常被用来把对话结果、搜索结果和设计结果合并成统一规格。



path: atomic-capability/document-generation/index.md
content: # 文档生成

## 适用场景

这个能力用于生成需求文档、设计文档、构建说明、能力说明、操作手册和最终 Skill 文档。

## 选择条件

- 需要把对话和搜索结果整理成正式文档。
- 需要输出结构稳定、可继续引用的材料。
- 需要把方案写成用户能直接阅读的说明。
- 需要把模板骨架填成完整产物。

## 输入

- 目标文档类型
- 受众
- 内容范围
- 语气要求
- 结构约束

## 输出

- 成文文档
- 标题结构
- 模块划分
- 缺口标记

## 边界

- 不把草稿当最终稿，必要时保留待确认项。
- 不把内部思考过程写进最终文档。
- 不把文档生成和真实执行混为一谈。

## 降级策略

- 信息不足时，先输出提纲，再补正文。
- 结构不稳定时，先固定章节，再填内容。
- 如果用户还没定稿，只生成可讨论版本。

## 与主流程的关系

这个能力常用于把调研阶段、设计阶段和构建阶段的结果串成统一文档链。



path: atomic-capability/external-service/index.md
content: # 外部服务接入

## 适用场景

这个能力用于接入 API、平台服务、身份鉴权、配置文件、回调、限流策略和外部工具。

## 选择条件

- 目标 Skill 必须依赖外部平台。
- 需要把手动操作变成稳定接入。
- 需要明确配置、鉴权和失败重试方式。
- 需要为外部依赖写出操作手册。

## 输入

- 服务名称
- 接入目标
- 鉴权方式
- 环境变量或配置项
- 成功与失败条件

## 输出

- 接入说明
- 配置清单
- 调用约束
- 风险提示
- 替代路径

## 最小 SOP

### 1. 先确认接入形态

- 是公开 API、私有 API，还是浏览器会话代理
- 是 bearer token、OAuth，还是 cookie/session
- 是一次性调用，还是长链路轮询

### 2. 再定义配置契约

至少写清这些信息：

- 环境变量名
- 必填参数
- 可选参数
- 速率限制
- 失败重试策略

### 3. 再决定降级方式

优先顺序：

- 实时调用
- 本地 fixture
- 手工导入 JSON
- 只输出接入说明

### 4. 最后补差异说明

如果不同平台的接入方式不同，需要额外写清：

- 哪个平台支持直接环境变量
- 哪个平台更适合走 CLI
- 哪个平台只能保留半自动流程

## 实操建议

- X、GitHub、Notion 这类 API 优先走“真实 API + fixture fallback”
- 会话强依赖网页登录的平台，优先把浏览器自动化与外部服务接入拆开
- 任何需要 token 的能力，都要在最终 Skill 里写明没有凭证时会如何降级

## 边界

- 不默认假设用户已经有权限。
- 不把不确定的接口细节写成确定事实。
- 不把接入等同于完成授权或账号配置。

## 降级策略

- 没有真实服务权限时，先给模拟配置和手工步骤。
- 接口不稳定时，保留兜底流程和超时说明。
- 外部服务不可用时，主流程继续，功能降级为说明态或半自动态。

## 与主流程的关系

这个能力适合和搜索、文档生成、模板映射一起使用，尤其适合需要把外部依赖写进最终 Skill 的场景。


path: atomic-capability/file-ops/index.md
content: # 文件读写与整理

## 适用场景

这个能力用于读取、拆分、合并、重写、归档和重排 Skill 相关文件，也用于把用户已有材料整理成更清晰的结构。

## 选择条件

- 需要查看现有文档或产物。
- 需要把长内容拆成更小模块。
- 需要生成新的目录结构或索引。
- 需要把用户输入整理成可交付的文档。

## 输入

- 文件路径
- 目标结构
- 保留规则
- 重排规则
- 输出格式

## 输出

- 读取摘要
- 重写后的内容
- 新目录建议
- 变更清单

## 边界

- 不默认做破坏性改写。
- 不替用户猜测未给出的内容。
- 不把二进制或复杂格式当成普通文本处理。

## 降级策略

- 只有只读权限时，输出结构建议和摘要。
- 不能批量改写时，按文件拆开逐个处理。
- 如果内容过长，先整理索引，再处理正文。

## 与主流程的关系

这个能力适合承接需求文档、设计文档、模板草案和最终 Skill 产物的整理工作。



path: atomic-capability/index.md
content: # 原子能力目录

## 定位

`atomic-capability/` 是 `cocoloop-skill-factory` 的原子能力路由层。
这里不承载具体 Skill 包，不承载动态服务端注册逻辑，只负责汇总当前可引用的原子能力说明，并为主流程提供稳定入口。

当前版本里，这个目录主要承载两类内容：

1. **本地静态能力 ref**
   - 已经写进仓库、可直接读取的能力说明
2. **未来动态能力入口**
   - 后续如果某些能力通过网络 API、注册中心或远端服务动态提供，也应当挂到同一套路由语义下

## 为什么改成 `atomic-capability/`

现在的目录更像“能力目录”而不是“实现集合”。
用单数目录名有两个好处：

- 更适合作为统一入口，主流程里只需要记住一个稳定路由
- 后续无论能力来自本地文档，还是来自动态注册，都能放进同一套索引语义

## 使用顺序

1. 先判定主任务域和并列任务域
2. 再读取对应 `presets/`
3. 然后进入 `atomic-capability/index.md` 选择可复用能力
4. 如果一个能力不够，再做组合
5. 如果本地没有合适能力，再记录缺口并考虑外部方案

## 路由结构

```text
atomic-capability/
  index.md
  browser-access/
  data-parse-transform/
  document-generation/
  external-service/
  file-ops/
  infographic-generation/
  presentation-generation/
  structured-visual-storytelling/
  search-and-info/
  subskill-invocation/
  template-mapping/
```

约定：

- 每个能力一个子目录
- 每个子目录默认入口是 `index.md`
- 如果某个能力有专项附录，放在同级子目录内
- 主流程和设计文档默认只引用 `atomic-capability/<capability>/index.md`

## 当前能力总览

| 能力 | 适用场景 | 当前来源 | 文档 |
| --- | --- | --- | --- |
| 搜索与信息获取 | 搜现成 Skill、补事实和上下文、整理参考 | 本地静态 | [search-and-info/index.md](./search-and-info/index.md) |
| 文件读写与整理 | 读取、改写、归档、重排文档与目录 | 本地静态 | [file-ops/index.md](./file-ops/index.md) |
| 数据解析与转换 | JSON、YAML、CSV、Markdown、表格之间互转 | 本地静态 | [data-parse-transform/index.md](./data-parse-transform/index.md) |
| 外部服务接入 | API、鉴权、回调、限流、配置接入 | 本地静态 | [external-service/index.md](./external-service/index.md) |
| 浏览器访问 | 页面查看、信息提取、表单与页面操作 | 本地静态 | [browser-access/index.md](./browser-access/index.md) |
| 结构化视觉叙事产物 | PPT、信息图、展示图、报告页等视觉叙事型产物的共享生产主线 | 本地静态 | [structured-visual-storytelling/index.md](./structured-visual-storytelling/index.md) |
| 信息图生成 | 长文信息图、视觉卡片、单页 poster、图卡导出 | 本地静态 | [infographic-generation/index.md](./infographic-generation/index.md) |
| PPT 生成 | 演示稿 brief、大纲、HTML slides、`.pptx` 导出 | 本地静态 | [presentation-generation/index.md](./presentation-generation/index.md) |
| 文档生成 | 需求文档、设计文档、Skill 文档、说明文档 | 本地静态 | [document-generation/index.md](./document-generation/index.md) |
| 子 Skill 调用 | 把复杂流程拆给子 Skill 或复用既有子流程 | 本地静态 | [subskill-invocation/index.md](./subskill-invocation/index.md) |
| 模板映射 | 把需求映射到平台模板与落地结构 | 本地静态 | [template-mapping/index.md](./template-mapping/index.md) |

## 选型原则

- 任务域预设优先于原子能力细分。
- 先选最小能力，再考虑组合。
- 先选“结构层”能力，再选“渲染层”能力。
- 能在文本层完成的，不先上浏览器。
- 能在本地文件层完成的，不先上外部服务。
- 模板映射负责落地方向，不负责直接替代其他能力。

## 视觉叙事产物的新规则

从近期深拆结果看，信息图、PPT、展示图和报告型页面都不能只被理解成“生成最终成品”。
后续使用时默认先进入 `structured-visual-storytelling`，再分流到不同 adapter。

默认顺序：

1. 先结构化内容
2. 再确认 `design_md`
3. 再检查文字层级
4. 再规划信息图元素
5. 再选择 `ppt`、`web_infographic`、`showcase_graphic` 等 adapter

## 动态能力扩展约定

后续如果引入网络 API 动态提供原子能力，建议仍然维持相同语义：

- `atomic-capability/index.md` 继续作为主目录
- 动态能力在索引表中标明来源为 `dynamic`
- 主流程先读目录，再决定能力来自本地还是远端

这样未来不会因为能力来源变化而重写主路由。

## 降级总则

- 本地没有匹配能力时，不阻断主流程，先继续研究和设计。
- 动态能力不可用时，优先退回本地静态能力。
- 外部服务不可用时，保留配置位和接入说明。
- 浏览器不可用时，转为文档输入、用户描述或手动核对。


path: atomic-capability/infographic-generation/index.md
content: # 信息图生成

## 当前定位

这个能力现在默认作为 `structured-visual-storytelling` 的 `web_infographic` 或 `showcase_graphic` adapter 使用。
共享规则优先读取：

- `../structured-visual-storytelling/index.md`
- `../structured-visual-storytelling/shared-rules.md`
- `../structured-visual-storytelling/output-adapters.md`

## 适用场景

这个能力用于生成单张或小批量的信息图、视觉卡片、知识海报、数据说明图和适合社媒传播的单页视觉产物。

它更适合承接：

- 长文压缩成单页信息图
- 研究结果或流程说明图
- 知识卡片、视觉笔记、一页纸总结
- 需要 HTML poster 或 PNG 成品的视觉交付

## 先做哪种判断

信息图能力现在默认分两条路径：

### 路径 1：分析驱动型信息图

适合：

- 文章、报告、研究内容
- 结构复杂、主题开放
- 需要先拆信息再谈版式

代表参考：

- `article-to-infographic`
- `baoyu-infographic`

### 路径 2：模板驱动型视觉卡片

适合：

- 知识卡片
- 单页 poster
- 社媒传播图卡
- 有明确固定视觉语法的单页成品

代表参考：

- `visual-note-card`

如果一开始没法判断，优先先按分析驱动型走；只有在版式语法已经很明确时，再走模板驱动型。

## 输入

- 主题
- 原始内容来源
  - 文章
  - 报告
  - 粘贴文本
  - 已整理要点
- 核心数字、引用、不能改写的文案
- 目标平台或画幅
- 风格方向
- 风格来源
  - 用户指定风格
  - 用户提供 `DESIGN.md`
  - 用户详细描述
  - 从 `ref/design-md/` 本地参考库中选起点
- 推荐在统一 spec 中继续固化 `design_md`，并在最终 Skill 中输出 `references/design.md`
- 产物要求
  - HTML
  - PNG
  - 两者都要

## 输出

- 选择哪条信息图路径
- 结构化中间层
- layout / style 或固定模板建议
- 文案保真和数字保真要求
- 输出链路与降级策略

## 最佳实践

### 1. 先做内容中间层，不直接做图

这类任务最稳定的做法都不是“原文 -> 最终图”。
至少先形成一个中间层。

进入视觉设计前，再加一条硬规则：

- 风格来源没定时，只允许做结构层和 layout 家族判断，不直接做高保真视觉稿
- 用户没有自己的品牌规范时，优先让用户从 `ref/design-md/` 里选一个起点
- 首批官方预设建议优先在 IBM、Stripe、Notion、Framer、Figma、Nothing、Apple 中选择

推荐最小中间层：

- `analysis.md`
- `structured-content.md`

建议内容：

#### `analysis.md`

- 主题
- 受众
- 内容类型
- 复杂度
- learning objectives
- verbatim data points
- 推荐 layout × style

#### `structured-content.md`

- 标题
- Overview
- Learning Objectives
- section 列表
- 每个 section 的 key concept / content / visual element / text labels
- 设计约束

### 2. layout 和 style 分开处理

`baoyu-infographic` 证明了信息图里“信息结构”和“视觉美学”不能混在一起。

建议默认拆成两个维度：

- `layout_family`
  - timeline
  - dashboard
  - comparison
  - process
  - listicle
  - magazine
- `visual_style`
  - clean-minimal
  - dark-techy
  - warm-editorial
  - bold-graphic
  - 其他风格族

先决定结构，再决定美学，稳定性会高很多。

### 3. 先确认 outline，再确认视觉

开放输入任务里，不要直接跳到视觉稿。
推荐顺序：

1. outline
2. layout
3. style source
4. style
5. 插画或图标
6. 输出格式

这条规则来自 `article-to-infographic`，很适合高不确定性任务。

### 4. 固定模板类任务要用“海报语法”

如果任务更像知识卡片或传播图卡，优先采用固定骨架，而不是做开放式版式探索。

可复用的模板语法来自 `visual-note-card`：

- top bar
- 标题区
- framework row
- 双栏正文区
- bottom highlight
- footer

这种结构特别适合：

- 知识笔记
- 学习卡片
- 单页分享图
- 观点型海报

## 如何拆解信息

### 分析驱动型

优先抽这些元素：

- 标题与副标题
- 关键统计
- 关键观点
- 原文引语
- 比较维度
- 时间顺序
- 自然分类
- 关键实体

再根据内容信号决定更适合：

- 时间线
- 数据仪表盘
- 对比图
- 流程图
- 卡片网格
- editorial 混排

### 模板驱动型

优先提炼：

- 一个强观点 thesis
- 一个 2 到 6 列 framework
- 左侧 narrative
- 右侧 numbered insights
- 一条可传播的 bottom formula

## 如何组织大纲

### 推荐流程

1. 定标题和副标题
2. 定 1 到 3 个 learning objectives
3. 拆 3 到 7 个 section
4. 为每个 section 指定：
   - 内容
   - 视觉元素
   - text labels
5. 确认最终是：
   - 单条 narrative poster
   - 多 section infographic
   - 知识卡片

### 推荐数量控制

- 单张信息图建议承载 3 到 7 个核心点
- 超过这个范围时，优先拆图或改做 deck
- 长文本、复杂表格、细密标签不适合直接硬塞进一张图

## 如何写文案

### 默认规则

- 不新增事实
- 不改写统计
- 引语尽量 verbatim
- 标题和标签优先服务视觉组织
- 文案要短、准、可扫描

### 模板驱动型的额外规则

- thesis 要有态度
- framework 名称要易记
- narrative 区偏“故事与转变”
- insights 区偏“编号与结论”
- bottom formula 要可传播

## 如何排版

### 信息图 HTML

建议把 HTML poster 当正式交付链路，而不是临时预览。

推荐排版规则：

- 总布局优先 CSS Grid
- 组件级布局用 Flex
- 单页宽度明确
- 间距紧凑
- 不做松散通用卡片拼盘
- 需要 `@media print`
- 需要 `prefers-reduced-motion`

### 视觉规则

- 数字要显著
- 图表和标签必须可核对
- 重要层级用标题、对比色、编号和区块来做
- 不要用通用 AI 风格默认稿

## 如何输出美观 HTML 和 PNG

### HTML

推荐把 HTML 作为主产物，因为这条链路最适合：

- 固定高保真版式
- 浏览器内检查
- 打印 PDF
- 再导出 PNG

### PNG

推荐走单独导出器，不要把 PNG 当作生成主链路。

当前可借鉴的稳定做法：

- Playwright 打开本地 HTML
- 强制 reveal / counter 到最终状态
- 截 `.poster` 或全页

推荐单独表达为：

- `html_poster`
- `png_export`

## 推荐执行方向

### 方向 1：HTML poster 优先

适合：

- 信息密度高
- 文案和数字需要可控
- 需要后续转 PNG 或 PDF

### 方向 2：图像生成优先

适合：

- 更偏氛围和视觉冲击
- 文本量较少
- 社媒传播图

这时仍然要先固定文案和数字源，不要把长文本完全交给位图生成。

### 方向 3：改做 deck 或可编辑版式

适合：

- 文本极多
- 表格极复杂
- 后续多人协作改稿

这时应转向 `presentation-generation` 或更强的可编辑版式工具。

## 边界

- 不把长篇报告硬做成一张图
- 不把复杂交互图表当成静态信息图
- 不默认位图可以承接高精度长文本
- 不把 HTML poster 和可编辑 deck 混成一个产物类型

## 降级策略

- 信息还没整理好时，先交付 `analysis.md` 和 `structured-content.md`
- 版式没定时，先输出 outline 和 layout 建议
- 文本太多时，改做 deck
- 风格没定时，先做低保真结构图

## 与主流程的关系

这个能力最适合挂在 `frontend_design` 主域下，也可以作为 `document_artifacts` 的补充能力出现。
当任务属于更广义的视觉叙事产物时，先走 `structured-visual-storytelling`，再落到这里。

如果进入正式设计比较，建议同时回看：

- [clawhub-infographic-ppt-deep-dive/reference-skill-analysis.md](/Users/tanshow/Developer/cocoloop-skill-factory-dev/cocoloop-skill-factory/output/clawhub-infographic-ppt-deep-dive/reference-skill-analysis.md)
- [clawhub-infographic-ppt-deep-dive/design-summary.md](/Users/tanshow/Developer/cocoloop-skill-factory-dev/cocoloop-skill-factory/output/clawhub-infographic-ppt-deep-dive/design-summary.md)


path: atomic-capability/presentation-generation/index.md
content: # PPT 生成

## 当前定位

这个能力现在默认作为 `structured-visual-storytelling` 的 `ppt` adapter 使用。
共享规则优先读取：

- `../structured-visual-storytelling/index.md`
- `../structured-visual-storytelling/shared-rules.md`
- `../structured-visual-storytelling/output-adapters.md`

## 适用场景

这个能力用于生成或修改演示稿，包括：

- 商业 pitch deck
- 研究总结 slides
- 培训材料
- 汇报型 `.pptx`
- HTML slides
- 需要多页结构的视觉化讲解页

## 先做哪种判断

演示稿能力现在默认拆成三层：

### 第 1 层：brief / interview

先确认：

- 主题
- 受众
- speaker
- work / evidence
- angle
- CTA

这一步也要先做问题预算。
如果用户提供的是零散口述，先规划最小访谈集，默认总问题数不得超过 10 个。
优先把主题、受众、核心材料、输出格式和风格来源问清，再决定是否补 speaker、CTA 或其他细节。
如果已有文档、摘要、提纲或历史 deck，可直接提取，不要重复访谈。

最好的参考是 `ai-presentation-maker`。

### 第 2 层：outline

把输入压成结构化 slide outline，而不是直接开做页面。

最好的参考是 `text-to-ppt` 的 JSON 大纲，以及 `ai-presentation-maker` 的 deck 结构。

### 第 3 层：render / export

最后再决定走：

- HTML slides
- `.pptx`
- PDF
- Gamma markdown

最好的参考是 `ppt-maker` 和 `ai-presentation-maker` 的导出层。

## 输入

- 演示目标
- 目标受众
- 参考材料
- speaker 信息
- 页数范围或节奏
- 风格与品牌要求
- 风格来源
  - 用户指定风格
  - 用户提供 `DESIGN.md`
  - 用户详细描述
  - 从 `ref/design-md/` 本地参考库中选起点
- 推荐在统一 spec 中继续固化 `design_md`，并在最终 Skill 中输出 `references/design.md`
- 是否必须可编辑
- 输出格式偏好

## 输出

- 推荐的演示稿执行方向
- 结构化 brief
- 结构化 outline
- 渲染与导出策略
- factual validation 和版式校验策略
- 降级路径

## 最佳实践

### 1. 先做 brief，再做 deck

这类任务最不稳定的做法，就是直接从主题跳到第一页。

如果任务强调视觉表达，再补一条硬规则：

- 在风格来源未明确前，不进入高保真页面设计
- 用户没有品牌规范时，优先让用户从 `ref/design-md/` 中选一个风格起点
- 首批官方预设建议优先在 IBM、Stripe、Notion、Framer、Figma、Nothing、Apple 中选择

推荐最小 brief：

- `presentation-brief.json`

建议字段：

- subject
- audience
- speaker
- work
- results
- costs
- mistakes
- angle
- resources
- CTA

这条做法直接来自 `ai-presentation-maker`。

### 2. 必须有结构化大纲

无论输入来自访谈、文档还是 Markdown，后续都建议统一沉到：

- `slide-outline.json`

每页最少包含：

- number
- type
- heading
- points 或 body
- data / chartData
- layout
- notes

这一步决定能否稳定并行生成、能否切换渲染器、能否做校验。

### 2.5 演示稿的版式与信息图硬规则

如果任务目标是正式汇报、毕业答辩、产品发布或研究总结，不能只交付“标题 + 长条目列表”。
默认要把信息拆成更强的视觉层次。

建议至少满足这些规则：

- 每页都要有明确的文字层级，例如 kicker、标题、摘要、数字、注释，不要让整页退化成同一层级的长条目
- 每个内容页至少出现一种非纯文本的信息图元素
  - metric card
  - process flow
  - comparison block
  - timeline
  - matrix
  - chart
  - module diagram
- 单页正文不要退化成 5 到 8 条长 bullet 的堆砌
- 标题负责结论，正文负责说明，数字和短句负责强调，注释负责补充边界
- 如果素材不足以支撑图表，也要用结构图、对比卡、数字块和关系箭头把信息可视化

对于毕业答辩或研究汇报，建议继续加一条默认要求：

- 全 deck 至少要有一页方法流程图、一页结果对比图、一页指标卡或核心数字页
- 全 deck 至少有 20% 的比例出现插图、图标标识或者网络搜索相关图片

### 3. HTML slides 和 `.pptx` 分开看

这两条链路都重要，但它们不是一个东西。

#### HTML slides

适合：

- 视觉质量优先
- 展示优先
- 浏览器直接播放
- 打印 PDF
- 强风格和单页控制

代表参考：

- `text-to-ppt`
- `ai-presentation-maker`

#### 原生 `.pptx`

适合：

- 需要 PowerPoint 可编辑性交付
- 企业协作改稿
- 后续继续改字、改图表、改布局

代表参考：

- `ppt-maker`
- `slides`

### 4. 演示稿要把“事实校验”当成正式能力

这轮最值得吸收的不是纯排版，而是 `ai-presentation-maker` 的 factual validation。

建议默认检查：

- speculative claims
- unverified numbers
- projections without caveat
- superlatives
- text overflow

这不该只放在 review 阶段，应该算 presentation 能力的一部分。

## 如何拆解信息

### 访谈驱动型

推荐按下面的顺序收集：

1. subject
2. audience
3. speaker
4. work
5. angle
6. resources / CTA

收集的核心目标不是聊天，而是为每个 slide 找到真实来源。
如果前 5 到 8 个问题里已经能形成稳定 brief，就直接收口做 outline，不要把访谈拖成完整问卷。

### 文本驱动型

如果输入已经是研究报告、总结、提案或计划文档，推荐先转成 slide outline，而不是直接写页面。

### Markdown 驱动型

如果输入本来已经很结构化，也可以走 Markdown DSL：

- `#` 封面
- `##` 分页
- `###` 页内标题
- 列表、表格、引用、代码块分别映射组件

这条路适合原生 `.pptx` 快速生产。

## 如何组织大纲

### 推荐流程

1. 定 angle
2. 定 narrative arc
3. 定 style source
4. 列 core slides
5. 判断 situational slides
6. 定 closing 和 CTA

建议最少要有：

- title
- hook
- problem
- what we built
- results
- CTA / closing

如果素材支持，再补：

- costs
- mistakes
- why now
- DIY path
- projections with caveat

### 每页结构建议

每页建议明确：

- slide_type
- title
- key message
- evidence
- visual intent
- speaker note hint

## 如何写文案

### 默认规则

- 先选 angle，再写文案
- slide 文案短而准
- 每页只承载一个核心意思
- 数字、结论、经验都要能回溯到素材

### 商业和汇报型演示稿

建议默认保留这些约束：

- 不编数字
- 不做空洞 superlative
- projection 必须有 caveat
- “What NOT to say” 值得保留在 speaker notes 里

### 工程化生成型演示稿

如果走 outline 或 Markdown DSL 路线，建议：

- 大纲先稳定
- 每页文案有长度上限
- 数据项显式声明是否可视化
- notes 单独存放，不混入正文

## 如何排版

### HTML slides

推荐的排版原则：

- 16:9
- 无滚动
- slide type 和 theme 分离
- 支持 notes 面板
- 支持打印 PDF
- 支持独立 per-slide HTML 或 combined deck

推荐单独表达：

- `slide_type`
- `theme`

### 原生 `.pptx`

推荐的排版原则：

- cover / content / ending 分页明确
- 主题色和图表色分离
- 列表、代码块、引用块、表格都有独立 renderer
- 图表页支持双栏布局

`ppt-maker` 的实现说明了：原生 `.pptx` 路线最实用的不是极致视觉，而是“结构清晰、可编辑、图表稳定”。

## 如何输出美观 HTML 和 `.pptx`

### HTML

最稳定的链路是：

1. 先 brief
2. 再 outline
3. 按页渲染
4. 最后 shell assemble

或者：

1. 先 Markdown deck
2. 再 combined HTML export
3. 再 per-slide HTML export

适合把 HTML 当主交付。

### `.pptx`

建议两条路分开看：

#### 路线 A：直接生成 `.pptx`

适合：

- 需要原生可编辑 deck
- 输入本来就结构化

可借鉴：

- `ppt-maker`
- `slides`

#### 路线 B：先有 deck，再导出 `.pptx`

适合：

- 已经先生成 Markdown deck
- 需要给 PowerPoint 用户一个可接手版本

可借鉴：

- `ai-presentation-maker/references/export-pptx.py`

### 导出器建议

后续建议单独表达这些导出器：

- `deck_markdown_to_html`
- `deck_markdown_to_pptx`
- `html_slides_to_pdf`

## 推荐执行方向

### 方向 1：`slides` 或原生 `.pptx` renderer

适合：

- 交付要求明确是 `.pptx`
- 需要多人协作改稿
- 图表和文本都要继续编辑

### 方向 2：HTML slides 优先

适合：

- 展示效果优先
- 强风格、强动画、强导出
- 可以浏览器直接播放

### 方向 3：混合链路

适合：

- 先做 HTML 确认视觉
- 再补 `.pptx` 交付

## 边界

- 不把最终 `.pptx` 等同于一组图片拼起来
- 不忽视字体替换和文本溢出
- 不在没有 outline 的情况下直接写整套 deck
- 不把 factual validation 当可选项

## 降级策略

- 信息不足时，先交付 brief 和 outline
- 页面过多时，先确定骨架页和示例页
- 风格没定时，先做结构版 deck
- 无法直接出 `.pptx` 时，先交付 HTML slides 或 Markdown deck

## 与主流程的关系

这个能力最适合挂在 `document_artifacts` 主域下，也可以和 `docs_research` 或 `frontend_design` 组合使用。
当任务属于更广义的视觉叙事产物时，先走 `structured-visual-storytelling`，再落到这里。

如果进入正式设计比较，建议同时回看：

- [clawhub-infographic-ppt-deep-dive/reference-skill-analysis.md](/Users/tanshow/Developer/cocoloop-skill-factory-dev/cocoloop-skill-factory/output/clawhub-infographic-ppt-deep-dive/reference-skill-analysis.md)
- [clawhub-infographic-ppt-deep-dive/design-summary.md](/Users/tanshow/Developer/cocoloop-skill-factory-dev/cocoloop-skill-factory/output/clawhub-infographic-ppt-deep-dive/design-summary.md)


path: atomic-capability/search-and-info/index.md
content: # 搜索与信息获取

## 适用场景

这个能力用于补充外部参考、查找现成 Skill、收集平台信息、确认依赖可用性，以及把零散线索整理成可用于设计的材料。

## 选择条件

- 用户目标已经有基本轮廓，但还缺参考方案。
- 需要判断是复用、改造，还是从零做。
- 需要补充平台、工具、库、环境等事实信息。
- 需要强制走 cocoloop 或 clawhub 搜索时。

## 输入

- 查询目标
- 查询范围
- 目标平台
- 期望来源
- 是否允许扩大搜索

## 输出

- 候选结果列表
- 每条结果的用途说明
- 相关性判断
- 可复用方式
- 风险与限制

## 边界

- 不直接替用户做最终设计判断。
- 不把搜索结果当成事实终点，仍要结合需求语境判断。
- 不把链接堆成结果，必须转成可行动的参考。

## 降级策略

- 外部搜索失败时，转为基于已有上下文的内部整理。
- 如果只拿到少量结果，就明确标记参考不足。
- 如果完全无法搜索，就继续推进需求澄清和方案草案，不停流程。

## 与主流程的关系

这个能力通常放在需求收敛后段和设计前段。它的任务是补参考，不是抢结论。



path: atomic-capability/structured-visual-storytelling/examples/graduate-defense.md
content: # 示例：研究生毕业答辩

## 输入

- 论文主题
- 研究背景
- 方法
- 实验结果
- 总结

## 共享主线收口

### story_units

- cover
- problem
- contribution
- method
- experiment
- result
- ablation
- closing

### 文字层级

- kicker：页型或章节提示
- headline：本页主结论
- summary：一句话解释
- body：实验、方法说明
- metric：结果数字
- annotation：边界与补充说明

### 信息图元素

- 背景页：问题卡片
- 方法页：流程图
- 结果页：指标卡与对比条
- 分析页：消融矩阵

## adapter 选择

- 需要可编辑性
- 需要正式答辩页结构
- 最终选 `ppt`


path: atomic-capability/structured-visual-storytelling/index.md
content: # 结构化视觉叙事产物

## 适用场景

这个能力用于批量生产各类“视觉叙事型” Skill，而不只是一种具体产物。

它适合承接：

- `.pptx`、HTML slides、答辩稿、汇报 deck
- 网页信息图、可滚动 narrative 页面、单页 poster
- 展示图、说明页、知识海报、报告型页面
- 后续还可能扩展到海报、长图、报告页等同类产物

这些产物的共同点不是格式，而是：

- 都要先有结构化内容层
- 都要先确定 `design_md`
- 都要控制文字层级
- 都要显式加入信息图元素
- 都要区分展示版、可编辑版和位图版输出

## 这层负责什么

这一层不直接替代 `presentation-generation` 或 `infographic-generation`。
它负责先定义共享主线，再把具体落地交给不同 adapter。

默认主线：

1. 先把原始内容压成 `story_units`
2. 再确认 `design_md`
3. 再检查文字层级
4. 再规划信息图元素
5. 再选择输出 adapter
6. 最后才进入具体渲染

如果跳过上面任一步，后续很容易退化成“把一堆字塞进 PPT”或“把长文硬排成信息图”。

## 通用输入

- 主题
- 原始材料
- 目标受众
- 叙事目标
- 输出载体
- 风格来源
- 是否需要可编辑性交付

## 通用输出

- 结构化叙事单元
- 设计约束
- 文字层级规则
- 信息图元素要求
- adapter 选择结果
- 验收与降级策略

## 强制门槛

### 1. 先结构化，再做视觉

无论目标产物是什么，都不允许“原文 -> 最终视觉稿”直接跳转。
至少要先形成一个内容中间层。

建议最小中间层：

- `story-units.md`
- `adapter-plan.md`

### 2. 必须有 `design_md`

只要任务进入正式视觉输出，就要继续补：

- `design_md.enabled`
- `design_md.source_mode`
- `design_md.preset_id` 或 `design_md.user_provided_ref`

没有 `design_md` 时，可以继续做结构方案，但不能直接进入高保真视觉输出。

### 3. 文字层级必须显式设计

不允许所有文字都在同一层级。
至少要区分：

- kicker 或章节标签
- 主标题
- 一句话结论或摘要
- 正文说明
- 数字或短句强调
- 注释或边界说明

### 4. 信息图元素不是点缀，而是主内容的一部分

不能把图表、流程、对比卡当作“有空再加”的视觉装饰。
对于视觉叙事产物，信息图元素本身就是内容表达方式。

默认至少规划：

- metric cards
- process flow
- comparison blocks
- timeline
- matrix
- chart
- module diagram

### 5. 输出必须走 adapter，而不是一层包打天下

同一套共享主线可以落到不同输出：

- `ppt`
- `web_infographic`
- `showcase_graphic`
- 后续还可扩到 `poster`、`report_page`

这一步决定：

- 版式语法
- 可编辑性边界
- 交付格式
- 测试方式

## 推荐读取顺序

1. [shared-rules.md](./shared-rules.md)
2. [output-adapters.md](./output-adapters.md)
3. 再进入具体 adapter：
   - `../presentation-generation/index.md`
   - `../infographic-generation/index.md`

## 与现有能力的关系

- `presentation-generation`
  现在作为 `ppt` adapter 理解
- `infographic-generation`
  现在作为 `web_infographic` 和 `showcase_graphic` adapter 理解

后续新增视觉叙事类能力时，也优先挂在这条主线下，不再各自重新发明完整流程。


path: atomic-capability/structured-visual-storytelling/output-adapters.md
content: # 输出适配器

## 目标

共享主线只负责把内容和视觉约束收口清楚。
真正落地到哪种产物，要通过 adapter 决定。

## 当前 adapter

### 1. `ppt`

默认落到：

- `presentation-generation`

适合：

- 汇报型 `.pptx`
- HTML slides
- 答辩稿
- 培训 deck

重点：

- cover / content / closing 分页明确
- 可编辑性优先
- 需要 speaker notes 时单独处理

### 2. `web_infographic`

默认落到：

- `infographic-generation`

适合：

- HTML poster
- 滚动 narrative infographic
- 知识可视化页面

重点：

- 结构先于风格
- 内容块要可扫描
- 适合继续导出 PNG / PDF

### 3. `showcase_graphic`

默认也可落到：

- `infographic-generation`

适合：

- 单页展示图
- 传播图卡
- 模块化功能说明图

重点：

- 单页强主张
- 信息密度可控
- 位图导出优先

## 后续预留 adapter

- `poster`
- `report_page`
- `interactive_story`

## 选择规则

1. 先看输出是否必须可编辑
2. 再看是否需要浏览器内播放或滚动
3. 再看文字量和信息密度
4. 最后才决定单页还是多页

## 适配器输出建议

每个 adapter 都继续明确：

- 输出格式
- 渲染语法
- 文字层级实现方式
- 信息图元素最低配额
- 降级路径


path: atomic-capability/structured-visual-storytelling/shared-rules.md
content: # 共享规则

## 1. 内容中间层

### 最小要求

所有视觉叙事产物都先整理成 `story_units`，再讨论视觉。

建议字段：

- `unit_id`
- `unit_role`
- `key_message`
- `evidence`
- `visual_intent`
- `text_priority`

### 常见 `unit_role`

- `cover`
- `hook`
- `problem`
- `method`
- `comparison`
- `result`
- `insight`
- `closing`

## 2. 文字层级规则

### 最小层级

至少区分：

- `kicker`
- `headline`
- `summary`
- `body`
- `metric`
- `annotation`

### 反模式

- 整页只有同字号的 bullet list
- 标题和正文只靠粗体区分
- 数字和结论埋在正文里
- 注释与主结论争抢视觉中心

## 3. 信息图元素规则

### 最小要求

每个内容页或内容面板都应至少规划一种非纯文本表达。

### 常见元素

- metric cards
- process flow
- comparison blocks
- timeline
- matrix
- chart
- module diagram

### 选择顺序

1. 先看信息关系是什么
2. 再选元素类型
3. 再决定视觉风格

不要反过来先决定“要做一个酷炫图”，再去塞内容。

## 4. `design_md` 规则

只要任务进入高保真视觉阶段，就补齐：

- `design_md.enabled`
- `design_md.applies_to`
- `design_md.source_mode`
- `design_md.preset_id` 或 `design_md.user_provided_ref`
- `design_md.custom_style_notes`

如果用户没有自带品牌规范，默认先从官方预设里选起点。

## 5. 输出边界

统一先判定输出属于哪类：

- 可编辑版
- 展示版
- 位图版

然后再选 adapter。

### 可编辑版

适合：

- `.pptx`
- HTML slides
- HTML poster

### 展示版

适合：

- 演示页
- 滚动 narrative 页面
- 浏览器直接播放的 deck

### 位图版

适合：

- 分享图
- 展示图
- 社媒传播图卡

## 6. 测试要求

统一检查：

- 是否退化成堆字
- 是否真的有文字层级
- 是否真的有信息图元素
- 是否和 `design_md` 一致
- 是否符合目标输出的可编辑性边界


path: atomic-capability/subskill-invocation/index.md
content: # 子 Skill 调用

## 适用场景

这个能力用于把复杂流程拆成多个子任务，并交给更合适的子 Skill 承接。

## 选择条件

- 主流程已经过长，不适合一口气完成。
- 某个子任务有稳定、可复用的专门流程。
- 需要把研究、设计、构建、测试拆开。
- 需要把一个大问题分给不同子能力处理。

## 输入

- 子任务目标
- 期望输出
- 约束条件
- 交接边界
- 成功标准

## 输出

- 子 Skill 调用目标
- 传入上下文
- 预期回传内容
- 失败后的回退路径

## 边界

- 不把单步任务硬拆成多层子 Skill。
- 不在没有明确边界时盲目交接。
- 不让子 Skill 之间互相循环依赖。

## 降级策略

- 子 Skill 不可用时，回到主流程内联完成。
- 子 Skill 输出不完整时，补一层主流程总结。
- 如果交接成本太高，就保留为主流程步骤。

## 与主流程的关系

这个能力非常适合 `brainstorm`、`skill-creator`、`benchmark` 这类阶段性子流程。



path: atomic-capability/template-mapping/index.md
content: # 模板映射

## 适用场景

这个能力用于把需求、平台、复杂度和依赖关系映射到合适的模板方案。

## 选择条件

- 已经知道目标平台。
- 已经知道 Skill 复杂度。
- 已经知道是否需要子 Skill。
- 已经知道是否依赖外部方案。

## 输入

- 平台名称
- Skill 类型
- 复杂度
- 依赖形态
- 子 Skill 需求

## 输出

- 推荐模板
- 选择理由
- 需要补的结构
- 备选模板
- 不匹配项

## 边界

- 不负责直接写最终模板正文。
- 不负责替代平台判断。
- 不负责替用户消除所有差异，只负责把差异摆清楚。

## 降级策略

- 没有明确模板时，先选最保守、最通用的模板。
- 平台信息不足时，先保留平台无关骨架。
- 如果多个模板都能用，就优先选最少改造成本的那个。

## 与主流程的关系

这个能力通常放在设计收敛后段，构建前段，用来把方案翻成真正能落地的结构。



path: factory-skill-builder/README.md
content: # Factory Skill Builder

这个目录属于 `cocoloop-skill-factory` 内部实现。

它只负责 `spec.yaml -> skill` 的生成链：

- 渲染最小 Skill 骨架
- 固化模板选择结果
- 做平台校验
- 在满足公开条件时执行平台打包

这里不是仓库根级通用脚手架，也不是最终生成出来的 Skill 目录。

## 依赖准备

运行这里的脚本前，先在当前目录准备 Node 依赖：

```bash
cd cocoloop-skill-factory/factory-skill-builder
npm install
```

当前最小依赖是 `yaml`，供 `spec.yaml` 解析与 frontmatter 生成使用。
打包阶段还要求系统中至少可用 `zip` 或 `tar` 其中一个命令。

## 元数据约束

所有由这条生成链产出的 `SKILL.md`，frontmatter 都会强制带上：

```yaml
generated_by_cocoloop: true
```

如需巡检或补写历史产物，可以单独运行：

```bash
node scripts/ensure_generated_by_cocoloop.cjs <skill-or-directory> --check
node scripts/ensure_generated_by_cocoloop.cjs <skill-or-directory> --fix
```

当目标是目录时，这个巡检只会处理同时包含 `SKILL.md` 和 `spec.yaml` 的生成产物目录，不会把手写主 Skill、子 Skill 或引用型 source skill 误判进去。

## 最小命令

```bash
node scripts/build_skill_from_spec.cjs ../output/preset-system-hardening/spec.yaml --out /tmp/cocoloop-build --platform codex --package
```

只拿着 `cocoloop-skill-factory` 子仓时，也可以只依赖当前目录下这条生成链完成 `spec.yaml -> skill` 的渲染、校验和打包。


path: factory-skill-builder/package-lock.json
content: {
  "name": "cocoloop-factory-skill-builder",
  "version": "0.3.5",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "cocoloop-factory-skill-builder",
      "version": "0.3.5",
      "dependencies": {
        "yaml": "^2.8.1"
      }
    },
    "node_modules/yaml": {
      "version": "2.8.3",
      "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "bin": {
        "yaml": "bin.mjs"
      },
      "engines": {
        "node": ">= 14.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/eemeli"
      }
    }
  }
}


path: factory-skill-builder/package.json
content: {
  "name": "cocoloop-factory-skill-builder",
  "private": true,
  "version": "0.3.5",
  "description": "Internal spec-driven skill builder for cocoloop-skill-factory",
  "scripts": {
    "test": "node --test tests/*.test.cjs",
    "regression": "node tests/regression.cjs"
  },
  "dependencies": {
    "yaml": "^2.8.1"
  }
}


path: factory-skill-builder/scripts/_spec_common.cjs
content: /**
 * Shared helpers for spec-driven skill rendering and validation.
 */

const fs = require('node:fs');
const path = require('node:path');

let yaml;
try {
  yaml = require('yaml');
} catch (error) {
  throw new Error(
    'Missing "yaml" package in the current Node environment. Install it or use an environment that provides it.',
  );
}

const SUPPORT_LEVELS = [
  'supported_public',
  'supported_authoring_only',
  'supported_local_only',
  'planned',
  'unverified',
];

const VALID_TARGET_PLATFORMS = [
  'codex',
  'claude_code',
  'openclaw',
  'hermes_agent',
  'copaw',
  'molili',
];

function ensureArray(value) {
  return Array.isArray(value) ? value : [];
}

function loadYamlFile(filePath) {
  const absolutePath = path.resolve(filePath);
  const raw = fs.readFileSync(absolutePath, 'utf8');
  const parsed = yaml.parse(raw);
  if (!parsed || typeof parsed !== 'object') {
    throw new Error(`Invalid YAML object in ${absolutePath}`);
  }
  return parsed;
}

function writeYamlFile(filePath, value) {
  fs.writeFileSync(
    filePath,
    yaml.stringify(value, {
      indent: 2,
      lineWidth: 0,
      defaultStringType: 'QUOTE_DOUBLE',
    }),
  );
}

function mkdirp(dirPath) {
  fs.mkdirSync(dirPath, { recursive: true });
}

function slugify(value) {
  return String(value || '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .replace(/-{2,}/g, '-');
}

function isValidSkillSlug(value) {
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(value || '').trim());
}

function titleCase(value) {
  return String(value || '')
    .split(/[-_\s]+/)
    .filter(Boolean)
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
    .join(' ');
}

function getSkillSlug(spec) {
  const candidates = [
    spec?.skill_identity?.slug,
    spec?.skill_identity?.id,
    spec?.skill_identity?.name,
    spec?.intent?.goal,
  ];
  for (const candidate of candidates) {
    const slug = slugify(candidate);
    if (slug) return slug;
  }
  throw new Error('Unable to derive skill slug from spec.');
}

function getDisplayName(spec) {
  return (
    spec?.skill_identity?.display_name ||
    spec?.skill_identity?.name ||
    titleCase(spec?.skill_identity?.id || '') ||
    titleCase(getSkillSlug(spec))
  );
}

function getDescription(spec) {
  const goal = String(spec?.intent?.goal || '').trim();
  if (goal) return goal;
  const scenarios = ensureArray(spec?.intent?.use_scenarios).filter(Boolean);
  if (scenarios.length) return `Use when the task matches: ${scenarios[0]}`;
  return `Use ${getDisplayName(spec)} to help with this task.`;
}

function getWhenToUse(spec) {
  const parts = [];
  const goal = String(spec?.intent?.goal || '').trim();
  if (goal) parts.push(goal);
  const scenarios = ensureArray(spec?.intent?.use_scenarios)
    .filter(Boolean)
    .slice(0, 3);
  if (scenarios.length) {
    parts.push(`Typical scenarios: ${scenarios.join('; ')}`);
  }
  return parts.join('. ').trim() || getDescription(spec);
}

function getTargetPlatforms(spec) {
  return ensureArray(spec?.skill_identity?.target_platforms);
}

function getTargetPlatformMap(spec) {
  return new Map(
    getTargetPlatforms(spec)
      .filter((item) => item && item.platform)
      .map((item) => [item.platform, item]),
  );
}

function getDuplicatePlatforms(spec) {
  const counts = new Map();
  for (const target of getTargetPlatforms(spec)) {
    if (!target?.platform) continue;
    counts.set(target.platform, (counts.get(target.platform) || 0) + 1);
  }
  return Array.from(counts.entries())
    .filter(([, count]) => count > 1)
    .map(([platform]) => platform);
}

function isKnownPlatform(platform) {
  return VALID_TARGET_PLATFORMS.includes(platform);
}

function isSupportedLevel(level) {
  return typeof level === 'string' && level.startsWith('supported_');
}

function getDependencyNames(spec, kind) {
  return ensureArray(spec?.dependencies)
    .filter((item) => item && (!kind || item.kind === kind))
    .map((item) => item.name)
    .filter(Boolean);
}

function toFrontmatter(frontmatter) {
  return `---\n${yaml.stringify(frontmatter, {
    indent: 2,
    lineWidth: 0,
    defaultStringType: 'QUOTE_DOUBLE',
  })}---\n`;
}

function renderMarkdownList(items) {
  return ensureArray(items)
    .filter(Boolean)
    .map((item) => `- ${item}`)
    .join('\n');
}

module.exports = {
  SUPPORT_LEVELS,
  VALID_TARGET_PLATFORMS,
  ensureArray,
  getDependencyNames,
  getDescription,
  getDuplicatePlatforms,
  getDisplayName,
  getSkillSlug,
  getTargetPlatformMap,
  getTargetPlatforms,
  getWhenToUse,
  isKnownPlatform,
  isSupportedLevel,
  loadYamlFile,
  mkdirp,
  renderMarkdownList,
  isValidSkillSlug,
  slugify,
  titleCase,
  toFrontmatter,
  writeYamlFile,
  yaml,
};


path: factory-skill-builder/scripts/build_skill_from_spec.cjs
content: #!/usr/bin/env node

const path = require('node:path');

const { loadYamlFile } = require('./_spec_common.cjs');
const { renderSkillFromSpec } = require('./render_skill_from_spec.cjs');
const { validatePlatformOutput } = require('./validate_platform_skill.cjs');
const { packageSkill } = require('./package_skill.cjs');

function parseArgs(argv) {
  const args = { force: false, package: false };
  for (let index = 0; index < argv.length; index += 1) {
    const [REDACTED_SECRET];
    if (!args.specPath) {
      args.specPath = token;
      continue;
    }
    if (token === '--out') {
      args.outDir = argv[index + 1];
      index += 1;
      continue;
    }
    if (token === '--platform') {
      args.platforms = argv[index + 1];
      index += 1;
      continue;
    }
    if (token === '--package') {
      args.package = true;
      continue;
    }
    if (token === '--force') {
      args.force = true;
      continue;
    }
    throw new Error(`Unknown argument: ${token}`);
  }
  if (!args.specPath || !args.outDir) {
    throw new Error(
      'Usage: node build_skill_from_spec.cjs <spec.yaml> --out <output-dir> [--platform codex,claude_code] [--package] [--force]',
    );
  }
  return args;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const renderResult = renderSkillFromSpec(args.specPath, args.outDir, {
    force: args.force,
    platforms: args.platforms
      ? args.platforms.split(',').map((value) => value.trim()).filter(Boolean)
      : null,
  });

  const validation = validatePlatformOutput(
    renderResult.skillDir,
    renderResult.renderedSpecPath,
  );
  if (!validation.valid) {
    for (const error of validation.errors) {
      console.error(`❌ ${error}`);
    }
    process.exit(1);
  }

  console.log(`✅ Rendered and validated ${renderResult.skillName}`);

  if (args.package) {
    const renderedSpec = loadYamlFile(renderResult.renderedSpecPath);
    const nonPublicTargets = (renderedSpec?.skill_identity?.target_platforms || []).filter(
      (target) => target.support_level !== 'supported_public',
    );
    if (nonPublicTargets.length > 0) {
      throw new Error(
        `Packaging is only allowed for supported_public targets. Non-public targets: ${nonPublicTargets.map((target) => target.platform).join(', ')}`,
      );
    }
    const packagedPath = packageSkill(renderResult.skillDir, path.resolve(args.outDir), {
      specPath: renderResult.renderedSpecPath,
    });
    console.log(`📦 Packaged to ${packagedPath}`);
  }
}

main().catch((error) => {
  console.error(`❌ ${error.message}`);
  process.exit(1);
});


path: factory-skill-builder/scripts/ensure_generated_by_cocoloop.cjs
content: #!/usr/bin/env node

const fs = require('node:fs');
const path = require('node:path');

const { toFrontmatter } = require('./_spec_common.cjs');
const { parseFrontmatter } = require('./validate_skill.cjs');

function parseArgs(argv) {
  const args = { mode: 'check' };
  for (let index = 0; index < argv.length; index += 1) {
    const [REDACTED_SECRET];
    if (token === '--check') {
      args.mode = 'check';
      continue;
    }
    if (token === '--fix') {
      args.mode = 'fix';
      continue;
    }
    if (!args.targetPath) {
      args.targetPath = token;
      continue;
    }
    throw new Error(`Unknown argument: ${token}`);
  }
  if (!args.targetPath) {
    throw new Error(
      'Usage: node ensure_generated_by_cocoloop.cjs <skill-or-directory> [--check|--fix]',
    );
  }
  return args;
}

function collectSkillMdFiles(targetPath) {
  const absolutePath = path.resolve(targetPath);
  if (!fs.existsSync(absolutePath)) {
    throw new Error(`Path does not exist: ${absolutePath}`);
  }
  const stat = fs.statSync(absolutePath);
  if (stat.isFile()) {
    if (path.basename(absolutePath) !== 'SKILL.md') {
      throw new Error(`Expected SKILL.md or a directory, got file: ${absolutePath}`);
    }
    return [absolutePath];
  }

  const skillFiles = [];
  walkForSkillMd(absolutePath, skillFiles);
  return skillFiles;
}

function walkForSkillMd(dirPath, skillFiles) {
  const directSkillMdPath = path.join(dirPath, 'SKILL.md');
  const directSpecPath = path.join(dirPath, 'spec.yaml');
  if (fs.existsSync(directSkillMdPath) && fs.existsSync(directSpecPath)) {
    skillFiles.push(directSkillMdPath);
  }

  for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
    if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '__pycache__') {
      continue;
    }
    const fullPath = path.join(dirPath, entry.name);
    if (entry.isDirectory()) {
      walkForSkillMd(fullPath, skillFiles);
    }
  }
}

function readSkillFrontmatter(filePath) {
  const content = fs.readFileSync(filePath, 'utf8');
  const match = content.match(/^(---\r?\n[\s\S]*?\r?\n---)(\r?\n|$)([\s\S]*)$/);
  if (!match) {
    throw new Error('No YAML frontmatter found');
  }
  return {
    content,
    frontmatter: parseFrontmatter(content),
    body: match[3],
  };
}

function ensureGeneratedByCocoloop(filePath, mode) {
  const { frontmatter, body } = readSkillFrontmatter(filePath);
  if (frontmatter.generated_by_cocoloop === true) {
    return { changed: false, valid: true };
  }
  if (mode === 'check') {
    return { changed: false, valid: false };
  }

  const nextFrontmatter = {
    ...frontmatter,
    generated_by_cocoloop: true,
  };
  fs.writeFileSync(filePath, `${toFrontmatter(nextFrontmatter)}${body}`);
  return { changed: true, valid: true };
}

function main() {
  const args = parseArgs(process.argv.slice(2));
  const skillFiles = collectSkillMdFiles(args.targetPath);
  if (skillFiles.length === 0) {
    throw new Error(
      `No generated SKILL.md files found under ${path.resolve(args.targetPath)}. Expected directories that contain both SKILL.md and spec.yaml.`,
    );
  }

  const invalidFiles = [];
  let changedCount = 0;

  for (const filePath of skillFiles) {
    const result = ensureGeneratedByCocoloop(filePath, args.mode);
    if (!result.valid) {
      invalidFiles.push(filePath);
      continue;
    }
    if (result.changed) {
      changedCount += 1;
      console.log(`fixed ${filePath}`);
    }
  }

  if (invalidFiles.length > 0) {
    for (const filePath of invalidFiles) {
      console.error(`missing generated_by_cocoloop=true: ${filePath}`);
    }
    process.exit(1);
  }

  if (args.mode === 'fix') {
    console.log(`checked ${skillFiles.length} SKILL.md files, updated ${changedCount}.`);
    return;
  }

  console.log(`checked ${skillFiles.length} SKILL.md files, all passed.`);
}

main();


path: factory-skill-builder/scripts/package_skill.cjs
content: #!/usr/bin/env node

/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * Skill Packager - Creates a distributable .skill file of a skill folder
 *
 * Usage:
 *     node package_skill.cjs <path/to/skill-folder> [output-directory]
 */

const path = require('node:path');
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const { loadYamlFile } = require('./_spec_common.cjs');
const { validateSkill } = require('./validate_skill.cjs');
const { validatePlatformOutput } = require('./validate_platform_skill.cjs');

function packageSkill(skillPathArg, outputDirArg, options = {}) {
  const skillPath = path.resolve(skillPathArg);
  const outputDir = outputDirArg ? path.resolve(outputDirArg) : process.cwd();
  const skillName = path.basename(skillPath);
  const inferredSpecPath =
    options.specPath ||
    (fs.existsSync(path.join(skillPath, 'spec.yaml'))
      ? path.join(skillPath, 'spec.yaml')
      : null);

  if (!inferredSpecPath) {
    throw new Error(
      'factory-skill-builder/package_skill.cjs requires a rendered skill with spec.yaml. Use a standalone packager outside factory-skill-builder for generic non-factory packaging.',
    );
  }

  const result = validateSkill(skillPath);
  if (!result.valid) {
    throw new Error(`Validation failed: ${result.message}`);
  }

  if (result.warning) {
    throw new Error(`${result.warning}. Please resolve all TODOs before packaging.`);
  }
  const platformValidation = validatePlatformOutput(skillPath, inferredSpecPath);
  if (!platformValidation.valid) {
    throw new Error(`Platform validation failed: ${platformValidation.errors.join('; ')}`);
  }
  const spec = loadYamlFile(inferredSpecPath);
  const nonPublicTargets = (spec?.skill_identity?.target_platforms || []).filter(
    (target) => target.support_level !== 'supported_public',
  );
  if (nonPublicTargets.length > 0) {
    throw new Error(
      `Packaging is only allowed for supported_public targets. Non-public targets: ${nonPublicTargets.map((target) => target.platform).join(', ')}`,
    );
  }

  fs.mkdirSync(outputDir, { recursive: true });

  const outputFilename = path.join(outputDir, `${skillName}.skill`);

  const zipExcludes = [
    '.git/*',
    '*/.git/*',
    '__pycache__/*',
    '*/__pycache__/*',
    '*.pyc',
    '.DS_Store',
    'node_modules/*',
    '*/node_modules/*',
    '.clawhub/*',
    '*/.clawhub/*',
  ];

  let zipProcess = spawnSync('zip', ['-r', outputFilename, '.', '-x', ...zipExcludes], {
    cwd: skillPath,
    stdio: 'inherit',
  });

  if (zipProcess.error || zipProcess.status !== 0) {
    console.log('zip command not found, falling back to tar...');
    zipProcess = spawnSync(
      'tar',
      [
        '--exclude=.git',
        '--exclude=*/.git',
        '--exclude=__pycache__',
        '--exclude=*/__pycache__',
        '--exclude=*.pyc',
        '--exclude=.DS_Store',
        '--exclude=node_modules',
        '--exclude=*/node_modules',
        '--exclude=.clawhub',
        '--exclude=*/.clawhub',
        '-a',
        '-c',
        '--format=zip',
        '-f',
        outputFilename,
        '.',
      ],
      {
        cwd: skillPath,
        stdio: 'inherit',
      },
    );
  }

  if (zipProcess.error) {
    throw zipProcess.error;
  }

  if (zipProcess.status !== 0) {
    throw new Error(`Packaging command failed with exit code ${zipProcess.status}`);
  }

  return outputFilename;
}

async function main() {
  const args = process.argv.slice(2);
  if (args.length < 1) {
    console.log(
      'Usage: node package_skill.cjs <path/to/skill-folder> [output-directory]',
    );
    process.exit(1);
  }

  const skillPathArg = args[0];
  const outputDirArg = args[1];

  if (
    skillPathArg.includes('..') ||
    (outputDirArg && outputDirArg.includes('..'))
  ) {
    console.error('❌ Error: Path traversal detected in arguments.');
    process.exit(1);
  }

  try {
    console.log('🔍 Validating skill...');
    const outputFilename = packageSkill(skillPathArg, outputDirArg);
    console.log('✅ Skill is valid!');
    console.log(`✅ Successfully packaged skill to: ${outputFilename}`);
  } catch (err) {
    console.error(`❌ Error packaging: ${err.message}`);
    process.exit(1);
  }
}

if (require.main === module) {
  main();
}

module.exports = { packageSkill };


path: factory-skill-builder/scripts/render_skill_from_spec.cjs
content: #!/usr/bin/env node

const fs = require('node:fs');
const path = require('node:path');

const TEMPLATE_DIR = path.resolve(
  __dirname,
  '..',
  '..',
  'utils',
  'template',
);
const DESIGN_MD_REF_DIR = path.resolve(
  __dirname,
  '..',
  '..',
  'ref',
  'design-md',
);
const PLATFORM_TEMPLATE_FILES = {
  codex: 'codex-skill-template.md',
  claude_code: 'claude-code-skill-template.md',
  openclaw: 'openclaw-skill-template.md',
  hermes_agent: 'hermes-agent-skill-template.md',
  copaw: 'copaw-skill-template.md',
  molili: 'molili-skill-template.md',
};

const {
  ensureArray,
  getDependencyNames,
  getDescription,
  getDisplayName,
  getSkillSlug,
  getTargetPlatformMap,
  getWhenToUse,
  loadYamlFile,
  mkdirp,
  renderMarkdownList,
  toFrontmatter,
  writeYamlFile,
} = require('./_spec_common.cjs');
const { assertRenderableSpec } = require('./schema_rules.cjs');

function parseArgs(argv) {
  const args = { force: false };
  for (let index = 0; index < argv.length; index += 1) {
    const [REDACTED_SECRET];
    if (!args.specPath) {
      args.specPath = token;
      continue;
    }
    if (token === '--out') {
      args.outDir = argv[index + 1];
      index += 1;
      continue;
    }
    if (token === '--platform') {
      args.platforms = argv[index + 1];
      index += 1;
      continue;
    }
    if (token === '--force') {
      args.force = true;
      continue;
    }
    throw new Error(`Unknown argument: ${token}`);
  }
  if (!args.specPath || !args.outDir) {
    throw new Error(
      'Usage: node render_skill_from_spec.cjs <spec.yaml> --out <output-dir> [--platform codex,claude_code] [--force]',
    );
  }
  return args;
}

function buildRenderedSpec(spec, selectedPlatforms) {
  const researchContract = getResearchInteractionContract(spec);
  return {
    ...spec,
    interaction_contract: {
      ...(spec.interaction_contract || {}),
      research: researchContract,
    },
    skill_identity: {
      ...(spec.skill_identity || {}),
      target_platforms: selectedPlatforms,
    },
  };
}

function buildSkillBody(spec, selectedPlatforms) {
  const scope = spec.scope || {};
  const researchInteraction = getResearchInteractionContract(spec);
  const maxQuestions = researchInteraction.max_questions;
  const countConfirmationQuestions = researchInteraction.count_confirmation_questions;
  const overflowStrategy = researchInteraction.overflow_strategy;
  const inputs = ensureArray(spec.inputs)
    .map((input) => `- \`${input.name}\`: ${input.description}`)
    .join('\n');
  const outputs = ensureArray(spec.outputs)
    .map((output) => `- \`${output.name}\` (${output.format}): ${output.description}`)
    .join('\n');
  const dependencies = ensureArray(spec.dependencies)
    .map((dependency) => `- \`${dependency.name}\` (${dependency.kind}): ${dependency.note}`)
    .join('\n');
  const designMd = spec?.design_md;
  const visualStorytelling = spec?.visual_storytelling;
  const designSection = designMd?.enabled
    ? [
        '## Design Reference',
        '',
        `- For visual output, follow \`${designMd.output_path || 'references/design.md'}\` before creating high-fidelity work.`,
        '- If the user provides a project-specific `DESIGN.md`, use it as the active visual constraint.',
        '- Use `references/design-md/` only when the user wants to switch to another bundled style reference.',
        '',
      ]
    : [];
  const visualStorytellingSection = visualStorytelling?.enabled
    ? [
        '## Visual Storytelling',
        '',
        '- Use `references/visual-storytelling.md` to plan story units, text hierarchy, visual structures, and adapter-specific output.',
        '- Keep visual work structured before moving into layout or asset production.',
        '',
      ]
    : [];
  const resourceItems = [
    '- `references/spec-summary.md`: confirmed scope, constraints, and delivery contract',
    '- `references/template-selection.md`: selected platform template references',
    designMd?.enabled
      ? `- \`${designMd.output_path || 'references/design.md'}\`: active visual design reference`
      : null,
    designMd?.enabled
      ? '- `references/design-md/`: bundled visual style references'
      : null,
    visualStorytelling?.enabled
      ? '- `references/visual-storytelling.md`: visual storytelling structure'
      : null,
  ].filter(Boolean);

  return [
    `# ${getDisplayName(spec)}`,
    '',
    '## Overview',
    '',
    String(spec.intent.goal || '').trim(),
    '',
    '## Use Cases',
    '',
    renderMarkdownList(spec.intent.use_scenarios),
    '',
    '## Inputs',
    '',
    inputs || '- No explicit inputs declared',
    '',
    '## Outputs',
    '',
    outputs || '- No explicit outputs declared',
    '',
    '## Workflow',
    '',
    '- Confirm the user goal, target environment, and execution plane when they are not already clear.',
    `- The full interaction should normally stay within ${maxQuestions} total questions${countConfirmationQuestions ? ', including confirmation questions.' : '.'}`,
    '- Ask only one key question per turn and use defaults, existing context, environment detection, or confirmations to reduce follow-up questions.',
    `- If open gaps remain near the question limit, apply \`${overflowStrategy}\` instead of extending the interview.`,
    '- Before implementation, check the bundled references and confirm any required external dependency.',
    '',
    ...designSection,
    ...visualStorytellingSection,
    '## Scope',
    '',
    'Required:',
    '',
    renderMarkdownList(scope.must_have) || '- None declared',
    '',
    'Out of scope:',
    '',
    renderMarkdownList(scope.excluded) || '- None declared',
    '',
    '## Platform Scope',
    '',
    selectedPlatforms
      .map(
        (platform) =>
          `- \`${platform.platform}\`${platform.note ? `: ${platform.note}` : ''}`,
      )
      .join('\n'),
    '',
    '## Dependencies',
    '',
    dependencies || '- No dependencies declared',
    '',
    '## Resources',
    '',
    resourceItems.join('\n'),
    '',
    '## Fallback Policy',
    '',
    spec?.fallback_policy?.allowed
      ? `- ${spec?.fallback_policy?.summary || 'Use the documented fallback path when the primary route is unavailable.'}`
      : '- No fallback path declared',
    '',
  ].join('\n');
}

function buildVisualStorytellingSummary(spec) {
  const visualStorytelling = spec.visual_storytelling || {};
  return [
    '# Visual Storytelling Summary',
    '',
    `- artifact_family: \`${visualStorytelling.artifact_family || ''}\``,
    `- output_adapters: ${ensureArray(visualStorytelling.output_adapters)
      .map((item) => `\`${item}\``)
      .join(', ') || 'None declared'}`,
    `- story_units: ${ensureArray(visualStorytelling.story_units)
      .map((item) => `\`${item}\``)
      .join(', ') || 'None declared'}`,
    `- text_hierarchy: ${ensureArray(visualStorytelling?.text_hierarchy?.required_layers)
      .map((item) => `\`${item}\``)
      .join(', ') || 'None declared'}`,
    `- infographic_required: ${visualStorytelling?.infographic_elements?.required ? 'yes' : 'no'}`,
    `- infographic_types: ${ensureArray(visualStorytelling?.infographic_elements?.allowed_types)
      .map((item) => `\`${item}\``)
      .join(', ') || 'None declared'}`,
    '',
  ].join('\n');
}

function getResearchInteractionContract(spec) {
  const researchContract = spec?.interaction_contract?.research || {};
  return {
    ask_one_question_per_turn:
      researchContract.ask_one_question_per_turn !== false,
    max_questions:
      Number.isFinite(researchContract.max_questions) &&
      researchContract.max_questions > 0
        ? researchContract.max_questions
        : 10,
    count_confirmation_questions:
      researchContract.count_confirmation_questions !== false,
    detect_current_environment_first:
      researchContract.detect_current_environment_first !== false,
    confirm_target_environment_before_writing:
      researchContract.confirm_target_environment_before_writing !== false,
    overflow_strategy:
      String(researchContract.overflow_strategy || '').trim() ||
      'write_open_gaps_then_continue',
  };
}

function writeCommonFiles(spec, skillDir, selectedPlatforms) {
  const slug = getSkillSlug(spec);
  const displayName = getDisplayName(spec);
  const outputProfile = spec.output_profile || {};
  const researchInteraction = getResearchInteractionContract(spec);
  const skillIdentityGate = spec?.research_gate?.skill_identity || {};
  const targetEnvironmentGate = spec?.research_gate?.target_environment || {};
  const implementationApproachGate = spec?.research_gate?.implementation_approach || {};
  const maxQuestions = researchInteraction.max_questions;
  const countConfirmationQuestions = researchInteraction.count_confirmation_questions;
  const detectCurrentEnvironmentFirst = researchInteraction.detect_current_environment_first;
  const confirmTargetEnvironmentBeforeWriting =
    researchInteraction.confirm_target_environment_before_writing;
  const overflowStrategy = researchInteraction.overflow_strategy;
  const frontmatter = {
    name: slug,
    description: getDescription(spec),
    version: spec?.skill_identity?.version || '0.1.0',
    author: spec?.skill_identity?.owner || 'unknown',
    generated_by_cocoloop: true,
  };

  if (selectedPlatforms.some((platform) => platform.platform === 'claude_code')) {
    frontmatter.when_to_use = getWhenToUse(spec);
    const allowedTools = getDependencyNames(spec, 'tool');
    if (allowedTools.length > 0) {
      frontmatter['allowed-tools'] = allowedTools;
    }
    frontmatter['user-invocable'] = true;
  }

  fs.writeFileSync(
    path.join(skillDir, 'SKILL.md'),
    `${toFrontmatter(frontmatter)}${buildSkillBody(spec, selectedPlatforms)}`,
  );

  mkdirp(path.join(skillDir, 'references'));
  fs.writeFileSync(
    path.join(skillDir, 'references', 'spec-summary.md'),
    [
      `# ${displayName} Spec Summary`,
      '',
      `- Skill Slug: \`${slug}\``,
      `- Display Name: ${displayName}`,
      `- Skill ID: \`${spec?.skill_identity?.id || slug}\``,
      `- Primary Domain: \`${spec.primary_domain || 'unspecified'}\``,
      `- Version: \`${spec?.skill_identity?.version || '0.1.0'}\``,
      `- Goal: ${spec?.intent?.goal || 'N/A'}`,
      '',
      '## Platforms',
      '',
      selectedPlatforms
        .map(
          (platform) =>
            `- \`${platform.platform}\`: ${platform.support_level} / ${platform.publish_mode || 'n/a'}`,
        )
        .join('\n'),
      '',
      '## Research Gates',
      '',
      `- Skill identity status: \`${skillIdentityGate.status || 'unspecified'}\``,
      `- Cocoloop slug check complete: ${skillIdentityGate.cocoloop_checked === true ? 'yes' : 'no'}`,
      `- ClawHub slug check complete: ${skillIdentityGate.clawhub_checked === true ? 'yes' : 'no'}`,
      `- Slug available: ${skillIdentityGate.slug_available === true ? 'yes' : 'no'}`,
      `- Target environment status: \`${targetEnvironmentGate.status || 'unspecified'}\``,
      `- Current environment: ${targetEnvironmentGate.current_environment || 'Unspecified'}`,
      `- Target environment: ${targetEnvironmentGate.target_environment || 'Unspecified'}`,
      `- Current environment is target: ${
        typeof targetEnvironmentGate.current_environment_is_target === 'boolean'
          ? targetEnvironmentGate.current_environment_is_target
            ? 'yes'
            : 'no'
          : 'unspecified'
      }`,
      `- Implementation approach status: \`${implementationApproachGate.status || 'unspecified'}\``,
      `- Selected execution plane: \`${implementationApproachGate.selected_execution_plane || 'unspecified'}\``,
      '',
      '## Design Input',
      '',
      spec?.design_md?.enabled
        ? `- Enabled: yes / source_mode: \`${spec.design_md.source_mode}\`${spec.design_md.preset_id ? ` / preset: \`${spec.design_md.preset_id}\`` : ''}`
        : '- Enabled: no',
      '',
      '## Output Profile',
      '',
      `- Has visual output: ${outputProfile.has_visual_output ? 'yes' : 'no'}`,
      `- Visual output types: ${ensureArray(outputProfile.visual_output_types)
        .map((item) => `\`${item}\``)
        .join(', ') || 'None declared'}`,
      '',
      '## Interaction Contract',
      '',
      `- Research max questions: \`${maxQuestions}\``,
      `- Count confirmation questions: ${countConfirmationQuestions ? 'yes' : 'no'}`,
      `- Detect current environment first: ${detectCurrentEnvironmentFirst ? 'yes' : 'no'}`,
      `- Confirm target environment before writing: ${confirmTargetEnvironmentBeforeWriting ? 'yes' : 'no'}`,
      `- Overflow strategy: \`${overflowStrategy}\``,
      '',
    ].join('\n'),
  );

  writeYamlFile(path.join(skillDir, 'spec.yaml'), spec);
  if (spec?.visual_storytelling?.enabled) {
    fs.writeFileSync(
      path.join(skillDir, 'references', 'visual-storytelling.md'),
      buildVisualStorytellingSummary(spec),
    );
  }
}

function isPathInside(parentDir, childPath) {
  const relativePath = path.relative(parentDir, childPath);
  return relativePath === '' || (
    !relativePath.startsWith('..') &&
    !path.isAbsolute(relativePath)
  );
}

function getDesignOutputPath(skillDir, designMd) {
  const relativePath = String(designMd?.output_path || 'references/design.md').trim();
  if (!relativePath || path.isAbsolute(relativePath)) {
    throw new Error('Spec design_md.output_path must be a relative path inside the rendered skill.');
  }
  const resolvedPath = path.resolve(skillDir, relativePath);
  const resolvedSkillDir = path.resolve(skillDir);
  if (!isPathInside(resolvedSkillDir, resolvedPath)) {
    throw new Error('Spec design_md.output_path must stay inside the rendered skill directory.');
  }
  return resolvedPath;
}

function getUserProvidedDesignPath(specPath, userProvidedRef) {
  const relativePath = String(userProvidedRef || '').trim();
  if (!relativePath || path.isAbsolute(relativePath)) {
    throw new Error('Spec design_md.user_provided_ref must be a relative path inside the spec directory.');
  }
  const specDir = path.resolve(path.dirname(specPath));
  const resolvedPath = path.resolve(specDir, relativePath);
  if (!fs.existsSync(resolvedPath)) {
    throw new Error(`design_md.user_provided_ref not found: ${resolvedPath}`);
  }
  const realSpecDir = fs.realpathSync(specDir);
  const realInputPath = fs.realpathSync(resolvedPath);
  if (!isPathInside(realSpecDir, realInputPath)) {
    throw new Error('Spec design_md.user_provided_ref must stay inside the spec directory.');
  }
  if (!fs.statSync(realInputPath).isFile()) {
    throw new Error(`design_md.user_provided_ref must point to a file: ${resolvedPath}`);
  }
  return realInputPath;
}

function buildCustomDesignMd(spec) {
  const designMd = spec.design_md || {};
  return [
    '# DESIGN.md',
    '',
    '## Use This First',
    '',
    'Use this document as the default visual constraint before producing any high-fidelity page, infographic, PPT, or showcase graphic.',
    '',
    '## Applies To',
    '',
    renderMarkdownList(ensureArray(designMd.applies_to)) || '- No explicit targets declared',
    '',
    '## Style Notes',
    '',
    renderMarkdownList(ensureArray(designMd.custom_style_notes)) || '- No explicit style notes declared',
    '',
    '## Fallback Rule',
    '',
    '- If the user provides a more specific DESIGN.md, prefer that file over this default brief.',
    '',
  ].join('\n');
}

function writeDesignMdFiles(spec, skillDir, specPath) {
  const designMd = spec?.design_md;
  const hasVisualOutput = spec?.output_profile?.has_visual_output === true;
  if (!designMd?.enabled && !hasVisualOutput) {
    return;
  }
  if (!designMd?.enabled && hasVisualOutput) {
    throw new Error(
      'Spec with output_profile.has_visual_output=true must enable design_md before design assets can be rendered.',
    );
  }

  const outputPath = getDesignOutputPath(skillDir, designMd);
  mkdirp(path.dirname(outputPath));

  const targetLibraryDir = path.join(skillDir, 'references', 'design-md');
  mkdirp(targetLibraryDir);

  const libraryFiles = fs
    .readdirSync(DESIGN_MD_REF_DIR)
    .filter((fileName) => fileName.endsWith('.md'));
  for (const fileName of libraryFiles) {
    fs.copyFileSync(
      path.join(DESIGN_MD_REF_DIR, fileName),
      path.join(targetLibraryDir, fileName),
    );
  }

  if (designMd.source_mode === 'preset') {
    const presetFileName = `${designMd.preset_id}.md`;
    const presetPath = path.join(DESIGN_MD_REF_DIR, presetFileName);
    if (!fs.existsSync(presetPath)) {
      throw new Error(`Unknown design_md preset "${designMd.preset_id}".`);
    }
    fs.copyFileSync(presetPath, outputPath);
  } else if (designMd.source_mode === 'user_provided') {
    const resolvedInputPath = getUserProvidedDesignPath(specPath, designMd.user_provided_ref);
    fs.copyFileSync(resolvedInputPath, outputPath);
  } else if (designMd.source_mode === 'custom_brief') {
    fs.writeFileSync(outputPath, buildCustomDesignMd(spec));
  } else {
    throw new Error(`Unsupported design_md.source_mode "${designMd.source_mode}".`);
  }

  fs.writeFileSync(
    path.join(skillDir, 'references', 'design-selection.md'),
    [
      '# Design Selection',
      '',
      `- source_mode: \`${designMd.source_mode}\``,
      designMd.preset_id ? `- preset_id: \`${designMd.preset_id}\`` : null,
      designMd.user_provided_ref
        ? `- user_provided_ref: \`${designMd.user_provided_ref}\``
        : null,
      `- design_entry: \`${path.relative(skillDir, outputPath) || 'references/design.md'}\``,
      '',
      designMd.prompt_user_to_use_first
        ? '- The generated skill should ask the user to read or replace this DESIGN.md before visual production.'
        : '- The generated skill keeps DESIGN.md as an optional reference.',
      '',
    ]
      .filter(Boolean)
      .join('\n'),
  );
}

function writeTemplateSelectionFiles(skillDir, selectedPlatforms) {
  const templateRefDir = path.join(skillDir, 'references', 'templates');
  mkdirp(templateRefDir);

  const filesToCopy = new Set(['spec-template.yaml']);
  for (const platform of selectedPlatforms) {
    const templateName = PLATFORM_TEMPLATE_FILES[platform.platform];
    if (templateName) filesToCopy.add(templateName);
  }

  for (const fileName of filesToCopy) {
    const sourcePath = path.join(TEMPLATE_DIR, fileName);
    if (!fs.existsSync(sourcePath)) {
      throw new Error(`Required template file is missing: ${sourcePath}`);
    }
    fs.copyFileSync(sourcePath, path.join(templateRefDir, fileName));
  }

  fs.writeFileSync(
    path.join(skillDir, 'references', 'template-selection.md'),
    [
      '# Template Selection',
      '',
      'The generated skill copied these template references from the factory baseline:',
      '',
      ...Array.from(filesToCopy).map((fileName) => `- \`${fileName}\``),
      '',
    ].join('\n'),
  );
}

function writeCodexManifest(spec, skillDir) {
  mkdirp(path.join(skillDir, 'agents'));
  writeYamlFile(path.join(skillDir, 'agents', 'openai.yaml'), {
    interface: {
      display_name: getDisplayName(spec),
      short_description: getDescription(spec),
      default_prompt: `Use $${getSkillSlug(spec)} to help with this task.`,
    },
    policy: {
      allow_implicit_invocation: true,
    },
  });
}

function writeClaudeManifest(spec, skillDir, platformInfo) {
  mkdirp(path.join(skillDir, 'platform-manifests'));
  writeYamlFile(path.join(skillDir, 'platform-manifests', 'claude-code.yaml'), {
    install_paths: [
      `~/.claude/skills/${getSkillSlug(spec)}`,
      `./.claude/skills/${getSkillSlug(spec)}`,
    ],
    support_level: platformInfo.support_level,
    standard_source: platformInfo.standard_source || '',
    validation_mode: platformInfo.validation_mode || '',
  });
}

function writeOpenClawManifest(spec, skillDir, platformInfo) {
  mkdirp(path.join(skillDir, 'platform-manifests'));
  const version = spec?.skill_identity?.version || '0.1.0';
  writeYamlFile(path.join(skillDir, 'platform-manifests', 'openclaw-publish.yaml'), {
    slug: getSkillSlug(spec),
    name: getDisplayName(spec),
    version,
    tags: [spec.primary_domain, ...ensureArray(spec.peer_domains)].filter(Boolean),
    changelog: `Release ${version}`,
    publish_command: `clawhub skill publish ${getSkillSlug(spec)} --slug ${getSkillSlug(spec)} --version ${version} --changelog "Release ${version}"`,
    standard_source: platformInfo.standard_source || '',
  });
}

function writeHermesManifest(spec, skillDir, platformInfo) {
  mkdirp(path.join(skillDir, 'platform-manifests'));
  writeYamlFile(path.join(skillDir, 'platform-manifests', 'hermes-agent.yaml'), {
    name: getDisplayName(spec),
    version: spec?.skill_identity?.version || '0.1.0',
    author: spec?.skill_identity?.owner || 'unknown',
    required_environment_variables: getDependencyNames(spec, 'env'),
    required_credential_files: getDependencyNames(spec, 'credential'),
    publish_target: platformInfo.publish_mode || 'hub_publish',
    standard_source: platformInfo.standard_source || '',
    preflight_checks: [
      'Verify required environment variables are documented before install',
      'Verify required credential files are documented before install',
      'Run security and trust review before hub publish',
    ],
  });
}

function writeCopawManifest(spec, skillDir, platformInfo) {
  mkdirp(path.join(skillDir, 'platform-manifests'));
  writeYamlFile(path.join(skillDir, 'platform-manifests', 'copaw-authoring.yaml'), {
    support_level: platformInfo.support_level,
    required_files: ['SKILL.md'],
    optional_directories: ['scripts', 'references', 'assets'],
    standard_source: platformInfo.standard_source || '',
  });
}

function writeMoliliManifest(spec, skillDir, platformInfo) {
  mkdirp(path.join(skillDir, 'platform-manifests'));
  const adapter = spec?.adapters?.molili || {};
  writeYamlFile(path.join(skillDir, 'platform-manifests', 'molili-install.yaml'), {
    support_level: platformInfo.support_level,
    source_root: adapter.source_root || '~/.cocoloop/skills',
    active_root:
      adapter.active_root || '~/.molili/workspaces/default/active_skills',
    activation_strategy: adapter.activation_strategy || 'symlink_then_copy',
    verification_steps:
      ensureArray(adapter.verification_steps).length > 0
        ? adapter.verification_steps
        : [
            'Verify SKILL.md exists in source directory',
            'Verify activated skill path exists in active_skills',
            'Invoke the skill once and confirm Molili discovers it',
          ],
  });
}

function renderSkillFromSpec(specPath, outDir, options = {}) {
  const spec = loadYamlFile(specPath);
  assertRenderableSpec(spec);
  const platformMap = getTargetPlatformMap(spec);
  const selectedPlatforms = options.platforms?.length
    ? options.platforms.map((platform) => {
        const info = platformMap.get(platform);
        if (!info) {
          throw new Error(`Platform "${platform}" not found in spec target_platforms.`);
        }
        return info;
      })
    : Array.from(platformMap.values());
  const renderedSpec = buildRenderedSpec(spec, selectedPlatforms);

  const skillDir = path.join(path.resolve(outDir), getSkillSlug(spec));
  if (fs.existsSync(skillDir)) {
    if (!options.force) {
      throw new Error(`Output directory already exists: ${skillDir}`);
    }
    fs.rmSync(skillDir, { recursive: true, force: true });
  }

  mkdirp(skillDir);
  writeCommonFiles(renderedSpec, skillDir, selectedPlatforms);
  writeTemplateSelectionFiles(skillDir, selectedPlatforms);
  writeDesignMdFiles(renderedSpec, skillDir, specPath);

  for (const platformInfo of selectedPlatforms) {
    switch (platformInfo.platform) {
      case 'codex':
        writeCodexManifest(renderedSpec, skillDir);
        break;
      case 'claude_code':
        writeClaudeManifest(renderedSpec, skillDir, platformInfo);
        break;
      case 'openclaw':
        writeOpenClawManifest(renderedSpec, skillDir, platformInfo);
        break;
      case 'hermes_agent':
        writeHermesManifest(renderedSpec, skillDir, platformInfo);
        break;
      case 'copaw':
        writeCopawManifest(renderedSpec, skillDir, platformInfo);
        break;
      case 'molili':
        writeMoliliManifest(renderedSpec, skillDir, platformInfo);
        break;
      default:
        throw new Error(`Unsupported render platform "${platformInfo.platform}".`);
    }
  }

  return {
    skillDir,
    skillName: getSkillSlug(spec),
    renderedSpecPath: path.join(skillDir, 'spec.yaml'),
    targetPlatforms: selectedPlatforms,
    platforms: selectedPlatforms.map((item) => item.platform),
  };
}

if (require.main === module) {
  try {
    const args = parseArgs(process.argv.slice(2));
    const result = renderSkillFromSpec(args.specPath, args.outDir, {
      force: args.force,
      platforms: args.platforms
        ? args.platforms.split(',').map((value) => value.trim()).filter(Boolean)
        : null,
    });
    console.log(`✅ Rendered skill at ${result.skillDir}`);
    console.log(`Platforms: ${result.platforms.join(', ')}`);
  } catch (error) {
    console.error(`❌ ${error.message}`);
    process.exit(1);
  }
}

module.exports = { renderSkillFromSpec };


path: factory-skill-builder/scripts/schema_rules.cjs
content: /**
 * Shared spec validation rules for rendering, platform validation, and tests.
 */

const {
  SUPPORT_LEVELS,
  getDisplayName,
  getDuplicatePlatforms,
  getTargetPlatforms,
  isKnownPlatform,
  isSupportedLevel,
  isValidSkillSlug,
} = require('./_spec_common.cjs');

const ALLOWED_GATE_STATUSES = new Set(['blocked', 'caution', 'ready']);
const ALLOWED_EXECUTION_PLANES = new Set([
  'Skill-only',
  'Skill + CLI',
  'Skill + API/MCP',
  'Skill + CLI + API/MCP',
]);

function pushRequiredString(errors, value, message) {
  if (!String(value || '').trim()) {
    errors.push(message);
  }
}

function validateTargetPlatforms(spec, errors, options = {}) {
  const targets = getTargetPlatforms(spec);
  const duplicates = getDuplicatePlatforms(spec);
  if (targets.length === 0) {
    errors.push('Spec must declare at least one target platform.');
    return;
  }
  if (duplicates.length > 0) {
    errors.push(`Spec declares duplicate target platforms: ${duplicates.join(', ')}`);
  }

  for (const target of targets) {
    if (!isKnownPlatform(target.platform)) {
      errors.push(`Unknown platform "${target.platform}" in spec target_platforms.`);
      continue;
    }
    if (options.requirePlatformSupportDetails) {
      if (!SUPPORT_LEVELS.includes(target.support_level)) {
        errors.push(
          `Platform "${target.platform}" uses unsupported support_level "${target.support_level}".`,
        );
      }
      if (isSupportedLevel(target.support_level)) {
        if (!target.standard_source) {
          errors.push(`Platform "${target.platform}" is missing standard_source.`);
        }
        if (!target.validation_mode) {
          errors.push(`Platform "${target.platform}" is missing validation_mode.`);
        }
        if (!target.publish_mode) {
          errors.push(`Platform "${target.platform}" is missing publish_mode.`);
        }
      }
    }
  }
}

function validateResearchGate(spec, errors, label) {
  const skillIdentityGate = spec?.research_gate?.skill_identity;
  const targetEnvironmentGate = spec?.research_gate?.target_environment;
  const implementationApproachGate = spec?.research_gate?.implementation_approach;

  if (!skillIdentityGate) {
    errors.push(`Spec must declare research_gate.skill_identity before ${label}.`);
  } else {
    if (!ALLOWED_GATE_STATUSES.has(String(skillIdentityGate.status || '').trim())) {
      errors.push('Spec research_gate.skill_identity.status must be blocked, caution, or ready.');
    } else if (String(skillIdentityGate.status || '').trim() !== 'ready') {
      errors.push(`Spec research_gate.skill_identity.status must be ready before ${label}.`);
    }
    if (typeof skillIdentityGate.cocoloop_checked !== 'boolean') {
      errors.push('Spec research_gate.skill_identity.cocoloop_checked must be boolean.');
    } else if (skillIdentityGate.cocoloop_checked !== true) {
      errors.push(`Spec research_gate.skill_identity.cocoloop_checked must be true before ${label}.`);
    }
    if (typeof skillIdentityGate.clawhub_checked !== 'boolean') {
      errors.push('Spec research_gate.skill_identity.clawhub_checked must be boolean.');
    } else if (skillIdentityGate.clawhub_checked !== true) {
      errors.push(`Spec research_gate.skill_identity.clawhub_checked must be true before ${label}.`);
    }
    if (typeof skillIdentityGate.slug_available !== 'boolean') {
      errors.push('Spec research_gate.skill_identity.slug_available must be boolean.');
    } else if (skillIdentityGate.slug_available !== true) {
      errors.push(`Spec research_gate.skill_identity.slug_available must be true before ${label}.`);
    }
  }

  if (!targetEnvironmentGate) {
    errors.push(`Spec must declare research_gate.target_environment before ${label}.`);
  } else {
    if (!ALLOWED_GATE_STATUSES.has(String(targetEnvironmentGate.status || '').trim())) {
      errors.push('Spec research_gate.target_environment.status must be blocked, caution, or ready.');
    } else if (String(targetEnvironmentGate.status || '').trim() !== 'ready') {
      errors.push(`Spec research_gate.target_environment.status must be ready before ${label}.`);
    }
    pushRequiredString(
      errors,
      targetEnvironmentGate.current_environment,
      `Spec research_gate.target_environment.current_environment is required before ${label}.`,
    );
    pushRequiredString(
      errors,
      targetEnvironmentGate.target_environment,
      `Spec research_gate.target_environment.target_environment is required before ${label}.`,
    );
    if (typeof targetEnvironmentGate.current_environment_is_target !== 'boolean') {
      errors.push('Spec research_gate.target_environment.current_environment_is_target must be boolean.');
    }
  }

  if (!implementationApproachGate) {
    errors.push(`Spec must declare research_gate.implementation_approach before ${label}.`);
  } else {
    if (!ALLOWED_GATE_STATUSES.has(String(implementationApproachGate.status || '').trim())) {
      errors.push('Spec research_gate.implementation_approach.status must be blocked, caution, or ready.');
    } else if (String(implementationApproachGate.status || '').trim() !== 'ready') {
      errors.push(`Spec research_gate.implementation_approach.status must be ready before ${label}.`);
    }
    if (!ALLOWED_EXECUTION_PLANES.has(String(implementationApproachGate.selected_execution_plane || '').trim())) {
      errors.push(
        'Spec research_gate.implementation_approach.selected_execution_plane must be one of Skill-only, Skill + CLI, Skill + API/MCP, or Skill + CLI + API/MCP.',
      );
    }
  }
}

function validateResearchContract(spec, errors) {
  const researchContract = spec?.interaction_contract?.research;
  if (!researchContract) return;

  if (
    researchContract.ask_one_question_per_turn !== undefined &&
    typeof researchContract.ask_one_question_per_turn !== 'boolean'
  ) {
    errors.push('Spec interaction_contract.research.ask_one_question_per_turn must be boolean when present.');
  }
  if (
    researchContract.count_confirmation_questions !== undefined &&
    typeof researchContract.count_confirmation_questions !== 'boolean'
  ) {
    errors.push('Spec interaction_contract.research.count_confirmation_questions must be boolean when present.');
  }
  if (
    researchContract.detect_current_environment_first !== undefined &&
    typeof researchContract.detect_current_environment_first !== 'boolean'
  ) {
    errors.push('Spec interaction_contract.research.detect_current_environment_first must be boolean when present.');
  }
  if (
    researchContract.confirm_target_environment_before_writing !== undefined &&
    typeof researchContract.confirm_target_environment_before_writing !== 'boolean'
  ) {
    errors.push('Spec interaction_contract.research.confirm_target_environment_before_writing must be boolean when present.');
  }
  if (researchContract.max_questions !== undefined) {
    if (!Number.isInteger(researchContract.max_questions) || researchContract.max_questions <= 0) {
      errors.push('Spec interaction_contract.research.max_questions must be a positive integer when present.');
    } else if (researchContract.max_questions > 10) {
      errors.push('Spec interaction_contract.research.max_questions must not exceed 10 for the current factory rules.');
    }
  }
  if (
    researchContract.overflow_strategy !== undefined &&
    !String(researchContract.overflow_strategy || '').trim()
  ) {
    errors.push('Spec interaction_contract.research.overflow_strategy must be a non-empty string when present.');
  }
}

function validateVisualSections(spec, errors) {
  const outputProfile = spec?.output_profile || {};

  if (typeof outputProfile.has_visual_output !== 'boolean') {
    errors.push('Spec output_profile.has_visual_output must be boolean before rendering or packaging.');
  }
  if (!Array.isArray(outputProfile.visual_output_types)) {
    errors.push('Spec output_profile.visual_output_types must be an array before rendering or packaging.');
  }
  if (outputProfile.has_visual_output && !spec?.design_md?.enabled) {
    errors.push('Spec with output_profile.has_visual_output=true must also enable design_md.');
  }

  if (spec?.design_md?.enabled) {
    if (!String(spec.design_md.source_mode || '').trim()) {
      errors.push('Spec must declare design_md.source_mode when design_md.enabled is true.');
    }
    if (!Array.isArray(spec.design_md.applies_to)) {
      errors.push('Spec must declare design_md.applies_to as an array when design_md.enabled is true.');
    }
    if (
      spec.design_md.source_mode === 'preset' &&
      !String(spec.design_md.preset_id || '').trim()
    ) {
      errors.push('Spec must declare design_md.preset_id when design_md.source_mode is preset.');
    }
    if (
      spec.design_md.source_mode === 'user_provided' &&
      !String(spec.design_md.user_provided_ref || '').trim()
    ) {
      errors.push(
        'Spec must declare design_md.user_provided_ref when design_md.source_mode is user_provided.',
      );
    }
  }

  if (spec?.visual_storytelling?.enabled) {
    if (!String(spec.visual_storytelling.artifact_family || '').trim()) {
      errors.push(
        'Spec must declare visual_storytelling.artifact_family when visual_storytelling.enabled is true.',
      );
    }
    if (!Array.isArray(spec.visual_storytelling.story_units) || spec.visual_storytelling.story_units.length === 0) {
      errors.push(
        'Spec must declare visual_storytelling.story_units as a non-empty array when visual_storytelling.enabled is true.',
      );
    }
    if (
      !Array.isArray(spec.visual_storytelling.output_adapters) ||
      spec.visual_storytelling.output_adapters.length === 0
    ) {
      errors.push(
        'Spec must declare visual_storytelling.output_adapters as a non-empty array when visual_storytelling.enabled is true.',
      );
    }
    if (
      !Array.isArray(spec?.visual_storytelling?.text_hierarchy?.required_layers) ||
      spec.visual_storytelling.text_hierarchy.required_layers.length === 0
    ) {
      errors.push(
        'Spec must declare visual_storytelling.text_hierarchy.required_layers as a non-empty array when visual_storytelling.enabled is true.',
      );
    }
    if (
      spec?.visual_storytelling?.infographic_elements?.required &&
      (!Array.isArray(spec.visual_storytelling.infographic_elements.allowed_types) ||
        spec.visual_storytelling.infographic_elements.allowed_types.length === 0)
    ) {
      errors.push(
        'Spec visual_storytelling.infographic_elements.allowed_types must be non-empty when infographic elements are required.',
      );
    }
  }
}

function collectSpecValidationErrors(spec, options = {}) {
  const errors = [];
  const label = options.label || 'rendering or packaging';

  if (!String(spec?.skill_identity?.slug || '').trim()) {
    errors.push(`Spec must declare skill_identity.slug before ${label}.`);
  } else if (!isValidSkillSlug(spec.skill_identity.slug)) {
    errors.push(
      'Spec skill_identity.slug must use lowercase English letters, digits, and hyphen separators only.',
    );
  }
  if (!String(spec?.skill_identity?.display_name || '').trim()) {
    errors.push(`Spec must declare skill_identity.display_name before ${label}.`);
  } else if (getDisplayName(spec).length > 20) {
    errors.push('Spec skill_identity.display_name must not exceed 20 characters.');
  }

  validateTargetPlatforms(spec, errors, options);

  pushRequiredString(errors, spec?.intent?.goal, `Spec intent.goal is required before ${label}.`);
  pushRequiredString(errors, spec?.primary_domain, `Spec primary_domain is required before ${label}.`);
  pushRequiredString(
    errors,
    spec?.research_evidence?.coverage_status?.status,
    `Spec research_evidence.coverage_status.status is required before ${label}.`,
  );

  if (!Array.isArray(spec?.peer_domains)) {
    errors.push(`Spec peer_domains must be an array before ${label}.`);
  }
  if (!Array.isArray(spec?.research_evidence?.open_gaps)) {
    errors.push(`Spec research_evidence.open_gaps must be an array before ${label}.`);
  }

  validateVisualSections(spec, errors);
  validateResearchGate(spec, errors, label);
  validateResearchContract(spec, errors);

  return errors;
}

function assertRenderableSpec(spec) {
  const errors = collectSpecValidationErrors(spec, {
    label: 'rendering',
    requirePlatformSupportDetails: false,
  });
  if (errors.length > 0) {
    throw new Error(errors.join('\n'));
  }
}

module.exports = {
  ALLOWED_EXECUTION_PLANES,
  ALLOWED_GATE_STATUSES,
  assertRenderableSpec,
  collectSpecValidationErrors,
};


path: factory-skill-builder/scripts/validate_platform_skill.cjs
content: #!/usr/bin/env node

const fs = require('node:fs');
const path = require('node:path');

const {
  getDisplayName,
  getSkillSlug,
  getTargetPlatforms,
  loadYamlFile,
} = require('./_spec_common.cjs');
const { collectSpecValidationErrors } = require('./schema_rules.cjs');
const { parseFrontmatter, validateSkill } = require('./validate_skill.cjs');

const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

function tryLoadYamlFile(filePath, errors, label) {
  try {
    return loadYamlFile(filePath);
  } catch (error) {
    errors.push(`${label}: ${error.message}`);
    return null;
  }
}

function isPathInside(parentDir, childPath) {
  const relativePath = path.relative(parentDir, childPath);
  return relativePath === '' || (
    !relativePath.startsWith('..') &&
    !path.isAbsolute(relativePath)
  );
}

function getSkillRelativePath(skillDir, relativePath, fieldName) {
  const normalizedPath = String(relativePath || '').trim();
  if (!normalizedPath || path.isAbsolute(normalizedPath)) {
    throw new Error(`Spec ${fieldName} must be a relative path inside the rendered skill.`);
  }
  const resolvedPath = path.resolve(skillDir, normalizedPath);
  const resolvedSkillDir = path.resolve(skillDir);
  if (!isPathInside(resolvedSkillDir, resolvedPath)) {
    throw new Error(`Spec ${fieldName} must stay inside the rendered skill directory.`);
  }
  return resolvedPath;
}

function validateGeneratedByCocoloop(frontmatter, errors) {
  if (frontmatter.generated_by_cocoloop !== true) {
    errors.push(
      'Factory-rendered SKILL.md frontmatter must include generated_by_cocoloop: true.',
    );
  }
}

function validateCodex(skillDir, errors, spec = null) {
  const filePath = path.join(skillDir, 'agents', 'openai.yaml');
  if (!fs.existsSync(filePath)) {
    return;
  }
  const manifest = tryLoadYamlFile(filePath, errors, 'agents/openai.yaml parse failed');
  if (!manifest) return;
  if (!manifest?.interface?.display_name) {
    errors.push('agents/openai.yaml is missing interface.display_name.');
  }
  if (!manifest?.interface?.short_description) {
    errors.push('agents/openai.yaml is missing interface.short_description.');
  }
  if (
    spec &&
    manifest?.interface?.display_name &&
    manifest.interface.display_name !== getDisplayName(spec)
  ) {
    errors.push('agents/openai.yaml interface.display_name must match spec skill_identity.display_name.');
  }
}

function validateClaude(skillDir, errors) {
  const skillMdPath = path.join(skillDir, 'SKILL.md');
  let frontmatter;
  try {
    frontmatter = parseFrontmatter(fs.readFileSync(skillMdPath, 'utf8'));
  } catch (error) {
    errors.push(`Claude Code frontmatter parse failed: ${error.message}`);
    return;
  }
  if (!frontmatter.when_to_use) {
    errors.push('Claude Code target requires when_to_use in SKILL.md frontmatter.');
  }
  if (
    frontmatter['allowed-tools'] !== undefined &&
    !Array.isArray(frontmatter['allowed-tools'])
  ) {
    errors.push('Claude Code frontmatter allowed-tools must be an array when present.');
  }
  if (
    frontmatter['user-invocable'] !== undefined &&
    typeof frontmatter['user-invocable'] !== 'boolean'
  ) {
    errors.push('Claude Code frontmatter user-invocable must be boolean when present.');
  }
}

function validateOpenClaw(skillDir, errors, spec = null) {
  const filePath = path.join(skillDir, 'platform-manifests', 'openclaw-publish.yaml');
  if (!fs.existsSync(filePath)) {
    errors.push('OpenClaw target requires platform-manifests/openclaw-publish.yaml.');
    return;
  }
  const manifest = tryLoadYamlFile(
    filePath,
    errors,
    'OpenClaw publish manifest parse failed',
  );
  if (!manifest) return;
  for (const field of ['slug', 'name', 'version', 'publish_command', 'changelog']) {
    if (!manifest[field]) {
      errors.push(`OpenClaw publish manifest is missing ${field}.`);
    }
  }
  if (manifest.version && !SEMVER_RE.test(String(manifest.version))) {
    errors.push('OpenClaw publish manifest version must be semver.');
  }
  if (!Array.isArray(manifest.tags)) {
    errors.push('OpenClaw publish manifest requires tags array.');
  }
  if (spec && manifest.slug && manifest.slug !== getSkillSlug(spec)) {
    errors.push('OpenClaw publish manifest slug must match spec skill_identity.slug.');
  }
  if (spec && manifest.name && manifest.name !== getDisplayName(spec)) {
    errors.push('OpenClaw publish manifest name must match spec skill_identity.display_name.');
  }
}

function validateHermes(skillDir, errors) {
  const filePath = path.join(skillDir, 'platform-manifests', 'hermes-agent.yaml');
  if (!fs.existsSync(filePath)) {
    errors.push('Hermes target requires platform-manifests/hermes-agent.yaml.');
    return;
  }
  const manifest = tryLoadYamlFile(filePath, errors, 'Hermes manifest parse failed');
  if (!manifest) return;
  for (const field of ['name', 'version', 'author']) {
    if (!manifest[field]) {
      errors.push(`Hermes manifest is missing ${field}.`);
    }
  }
  if (!Array.isArray(manifest.required_environment_variables)) {
    errors.push('Hermes manifest requires required_environment_variables array.');
  }
  if (!Array.isArray(manifest.required_credential_files)) {
    errors.push('Hermes manifest requires required_credential_files array.');
  }
  if (!Array.isArray(manifest.preflight_checks) || manifest.preflight_checks.length === 0) {
    errors.push('Hermes manifest requires preflight_checks array.');
  }
}

function validateCopaw(skillDir, errors) {
  const filePath = path.join(skillDir, 'platform-manifests', 'copaw-authoring.yaml');
  if (!fs.existsSync(filePath)) {
    errors.push('CoPaw target requires platform-manifests/copaw-authoring.yaml.');
    return;
  }
  const manifest = tryLoadYamlFile(filePath, errors, 'CoPaw manifest parse failed');
  if (!manifest) return;
  if (!Array.isArray(manifest.required_files) || !manifest.required_files.includes('SKILL.md')) {
    errors.push('CoPaw manifest must declare SKILL.md in required_files.');
  }
}

function validateMolili(skillDir, errors) {
  const filePath = path.join(skillDir, 'platform-manifests', 'molili-install.yaml');
  if (!fs.existsSync(filePath)) {
    errors.push('Molili target requires platform-manifests/molili-install.yaml.');
    return;
  }
  const manifest = tryLoadYamlFile(filePath, errors, 'Molili manifest parse failed');
  if (!manifest) return;
  for (const field of ['source_root', 'active_root', 'activation_strategy']) {
    if (!manifest[field]) {
      errors.push(`Molili install manifest is missing ${field}.`);
    }
  }
  if (!Array.isArray(manifest.verification_steps) || manifest.verification_steps.length === 0) {
    errors.push('Molili install manifest requires verification_steps.');
  }
}

function validatePlatformOutput(skillDir, specPath = null) {
  const result = validateSkill(skillDir);
  const errors = [];
  const warnings = [];

  if (!result.valid) {
    errors.push(result.message);
  }
  if (result.warning) {
    warnings.push(result.warning);
  }

  let resolvedSpecPath = specPath;
  if (!resolvedSpecPath) {
    const inferredSpecPath = path.join(skillDir, 'spec.yaml');
    if (fs.existsSync(inferredSpecPath)) {
      resolvedSpecPath = inferredSpecPath;
    } else {
      errors.push('Platform validation requires spec.yaml or an explicit --spec path.');
    }
  }

  let spec = null;
  if (resolvedSpecPath) {
    spec = tryLoadYamlFile(resolvedSpecPath, errors, 'Spec parse failed');
    if (spec) {
      errors.push(
        ...collectSpecValidationErrors(spec, {
          label: 'rendering or packaging',
          requirePlatformSupportDetails: true,
        }),
      );
    }
  }

  const skillMdPath = path.join(skillDir, 'SKILL.md');
  if (!fs.existsSync(path.join(skillDir, 'references', 'spec-summary.md'))) {
    errors.push('Rendered skill is missing references/spec-summary.md.');
  }
  if (!fs.existsSync(path.join(skillDir, 'references', 'template-selection.md'))) {
    errors.push('Rendered skill is missing references/template-selection.md.');
  }
  if (!fs.existsSync(path.join(skillDir, 'spec.yaml'))) {
    errors.push('Rendered skill is missing spec.yaml copy.');
  }
  if (!fs.existsSync(skillMdPath)) {
    errors.push('Rendered skill is missing SKILL.md.');
  } else {
    try {
      const frontmatter = parseFrontmatter(fs.readFileSync(skillMdPath, 'utf8'));
      validateGeneratedByCocoloop(frontmatter, errors);
    } catch (error) {
      errors.push(`Rendered SKILL.md frontmatter parse failed: ${error.message}`);
    }
  }

  if (spec) {
    let designEntry = null;
    if (spec?.design_md?.enabled || spec?.output_profile?.has_visual_output === true) {
      try {
        designEntry = getSkillRelativePath(
          skillDir,
          spec?.design_md?.output_path || 'references/design.md',
          'design_md.output_path',
        );
      } catch (error) {
        errors.push(error.message);
      }
    }
    if (spec?.design_md?.enabled) {
      if (designEntry && !fs.existsSync(designEntry)) {
        errors.push(`Rendered skill is missing ${path.relative(skillDir, designEntry)}.`);
      }
      if (!fs.existsSync(path.join(skillDir, 'references', 'design-md', 'index.md'))) {
        errors.push('Rendered skill is missing references/design-md/index.md.');
      }
      if (!fs.existsSync(path.join(skillDir, 'references', 'design-selection.md'))) {
        errors.push('Rendered skill is missing references/design-selection.md.');
      }
    }
    if (spec?.output_profile?.has_visual_output === true) {
      if (designEntry && !fs.existsSync(designEntry)) {
        errors.push(
          `Rendered skill with visual output is missing ${path.relative(skillDir, designEntry)}.`,
        );
      }
      if (!fs.existsSync(path.join(skillDir, 'references', 'design-md', 'index.md'))) {
        errors.push('Rendered skill with visual output is missing references/design-md/index.md.');
      }
    }
    if (spec?.visual_storytelling?.enabled) {
      if (!fs.existsSync(path.join(skillDir, 'references', 'visual-storytelling.md'))) {
        errors.push('Rendered skill is missing references/visual-storytelling.md.');
      }
    }
    for (const target of getTargetPlatforms(spec)) {
      switch (target.platform) {
        case 'codex':
          validateCodex(skillDir, errors, spec);
          break;
        case 'claude_code':
          validateClaude(skillDir, errors);
          break;
        case 'openclaw':
          validateOpenClaw(skillDir, errors, spec);
          break;
        case 'hermes_agent':
          validateHermes(skillDir, errors);
          break;
        case 'copaw':
          validateCopaw(skillDir, errors);
          break;
        case 'molili':
          validateMolili(skillDir, errors);
          break;
        default:
          errors.push(`No platform validator registered for "${target.platform}".`);
      }
    }
  }

  return {
    valid: errors.length === 0,
    errors,
    warnings,
  };
}

if (require.main === module) {
  const args = process.argv.slice(2);
  if (args.length < 1) {
    console.error(
      'Usage: node validate_platform_skill.cjs <skill-directory> [--spec <spec.yaml>]',
    );
    process.exit(1);
  }

  const skillDir = path.resolve(args[0]);
  let specPath = null;
  for (let index = 1; index < args.length; index += 1) {
    if (args[index] === '--spec') {
      specPath = path.resolve(args[index + 1]);
      index += 1;
      continue;
    }
    console.error(`Unknown argument: ${args[index]}`);
    process.exit(1);
  }

  if (!specPath) {
    const inferredSpecPath = path.join(skillDir, 'spec.yaml');
    if (!fs.existsSync(inferredSpecPath)) {
      console.error(
        '❌ Platform validation requires spec.yaml. Pass --spec <spec.yaml> or validate a rendered skill directory that already contains spec.yaml.',
      );
      process.exit(1);
    }
    specPath = inferredSpecPath;
  }

  const result = validatePlatformOutput(skillDir, specPath);
  for (const warning of result.warnings) {
    console.warn(`⚠️  ${warning}`);
  }
  if (!result.valid) {
    for (const error of result.errors) {
      console.error(`❌ ${error}`);
    }
    process.exit(1);
  }
  console.log('✅ Platform validation passed.');
}

module.exports = { validatePlatformOutput };


path: factory-skill-builder/scripts/validate_skill.cjs
content: /**
 * Local validation helpers for factory-rendered skills.
 * Kept inside factory-skill-builder so the spec-driven build chain
 * can run without depending on repository-root scaffold files.
 */

const fs = require('node:fs');
const path = require('node:path');
const yaml = require('yaml');
const TODO_MARKER = ['TODO', ':'].join('');

function parseFrontmatter(content) {
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
  if (!match) {
    throw new Error('No YAML frontmatter found');
  }
  const frontmatterText = match[1];
  const frontmatter = yaml.parse(frontmatterText);
  if (!frontmatter || typeof frontmatter !== 'object') {
    throw new Error('Invalid YAML frontmatter');
  }
  return frontmatter;
}

function isTextLikeFile(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  return !['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.zip', '.skill'].includes(ext);
}

function getAllFiles(dir, fileList = []) {
  const files = fs.readdirSync(dir);
  files.forEach((file) => {
    const name = path.join(dir, file);
    if (fs.statSync(name).isDirectory()) {
      if (!['node_modules', '.git', '__pycache__'].includes(file)) {
        getAllFiles(name, fileList);
      }
    } else {
      fileList.push(name);
    }
  });
  return fileList;
}

function validateSkill(skillPath) {
  if (!fs.existsSync(skillPath) || !fs.statSync(skillPath).isDirectory()) {
    return { valid: false, message: `Path is not a directory: ${skillPath}` };
  }

  const skillMdPath = path.join(skillPath, 'SKILL.md');
  if (!fs.existsSync(skillMdPath)) {
    return { valid: false, message: 'SKILL.md not found' };
  }

  const content = fs.readFileSync(skillMdPath, 'utf8');
  let frontmatter;
  try {
    frontmatter = parseFrontmatter(content);
  } catch (error) {
    return { valid: false, message: error.message };
  }

  const name = String(frontmatter.name || '').trim();
  const descriptionValue = frontmatter.description;

  if (!name) return { valid: false, message: 'Missing "name" in frontmatter' };
  if (typeof descriptionValue !== 'string') {
    return {
      valid: false,
      message: 'Description must be a single-line string: description: ...',
    };
  }

  const description = descriptionValue.trim();
  if (!description) {
    return {
      valid: false,
      message: 'Description must be a single-line string: description: ...',
    };
  }

  if (description.includes('\n')) {
    return {
      valid: false,
      message: 'Description must be a single line (no newlines)',
    };
  }

  if (!/^[a-z0-9-]+$/.test(name)) {
    return { valid: false, message: `Name "${name}" should be hyphen-case` };
  }

  if (description.length > 1024) {
    return { valid: false, message: 'Description is too long (max 1024)' };
  }

  const files = getAllFiles(skillPath);
  for (const file of files) {
    if (!isTextLikeFile(file)) {
      continue;
    }
    const fileContent = fs.readFileSync(file, 'utf8');
    if (fileContent.includes(TODO_MARKER)) {
      return {
        valid: true,
        message: 'Skill has unresolved TODOs',
        warning: `Found unresolved TODO in ${path.relative(skillPath, file)}`,
      };
    }
  }

  return { valid: true, message: 'Skill is valid!' };
}

module.exports = { parseFrontmatter, validateSkill };


path: factory-skill-builder/tests/regression.cjs
content: #!/usr/bin/env node

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const { renderSkillFromSpec } = require('../scripts/render_skill_from_spec.cjs');
const { validatePlatformOutput } = require('../scripts/validate_platform_skill.cjs');

const skillRoot = path.resolve(__dirname, '..', '..');
const outputRoot = path.join(skillRoot, 'output');

function walk(dir, results = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      walk(fullPath, results);
    } else if (entry.isFile() && entry.name === 'spec.yaml') {
      results.push(fullPath);
    }
  }
  return results;
}

function isSourceSpec(specPath) {
  return !fs.existsSync(path.join(path.dirname(specPath), 'SKILL.md'));
}

function runRegression() {
  const specs = walk(outputRoot).filter(isSourceSpec).sort();
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cocoloop-factory-regression-'));
  const failures = [];

  for (const specPath of specs) {
    const relative = path.relative(skillRoot, specPath);
    try {
      const result = renderSkillFromSpec(specPath, tempDir, { force: true });
      const validation = validatePlatformOutput(result.skillDir, result.renderedSpecPath);
      if (!validation.valid) {
        failures.push(`${relative}\n  ${validation.errors.join('\n  ')}`);
      } else {
        console.log(`ok ${relative}`);
      }
    } catch (error) {
      failures.push(`${relative}\n  ${error.message}`);
    }
  }

  if (failures.length > 0) {
    console.error(`\n${failures.length} regression spec(s) failed:`);
    for (const failure of failures) {
      console.error(`- ${failure}`);
    }
    process.exit(1);
  }

  console.log(`\nRendered and validated ${specs.length} source spec(s).`);
}

runRegression();


path: factory-skill-builder/tests/schema_rules.test.cjs
content: const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');

const { loadYamlFile } = require('../scripts/_spec_common.cjs');
const { renderSkillFromSpec } = require('../scripts/render_skill_from_spec.cjs');
const { collectSpecValidationErrors } = require('../scripts/schema_rules.cjs');
const { validatePlatformOutput } = require('../scripts/validate_platform_skill.cjs');

const skillRoot = path.resolve(__dirname, '..', '..');
const validSpecPath = path.join(
  skillRoot,
  'output',
  'preset-system-hardening',
  'spec.yaml',
);

function loadValidSpec() {
  return loadYamlFile(validSpecPath);
}

test('shared schema rules accept a known valid spec', () => {
  const errors = collectSpecValidationErrors(loadValidSpec(), {
    label: 'rendering or packaging',
    requirePlatformSupportDetails: true,
  });
  assert.deepEqual(errors, []);
});

test('shared schema rules reject unavailable slugs', () => {
  const spec = loadValidSpec();
  spec.research_gate.skill_identity.slug_available = false;

  const errors = collectSpecValidationErrors(spec, {
    label: 'rendering',
    requirePlatformSupportDetails: true,
  });

  assert.match(errors.join('\n'), /slug_available must be true/);
});

test('shared schema rules require design_md for visual output', () => {
  const spec = loadValidSpec();
  spec.output_profile.has_visual_output = true;
  spec.output_profile.visual_output_types = ['ppt'];
  spec.design_md = { enabled: false };

  const errors = collectSpecValidationErrors(spec, {
    label: 'rendering',
    requirePlatformSupportDetails: true,
  });

  assert.match(errors.join('\n'), /has_visual_output=true must also enable design_md/);
});

test('render rejects design output paths that escape the skill directory', () => {
  const specPath = path.join(
    skillRoot,
    'output',
    'design-md-hardening',
    'spec.yaml',
  );
  const spec = loadYamlFile(specPath);
  spec.design_md.output_path = '../design.md';

  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cocoloop-schema-test-'));
  const badSpecPath = path.join(tempDir, 'spec.yaml');
  fs.writeFileSync(badSpecPath, require('yaml').stringify(spec));

  assert.throws(
    () => renderSkillFromSpec(badSpecPath, tempDir, { force: true }),
    /design_md\.output_path must stay inside the rendered skill directory/,
  );
});

test('rendered design skill passes platform validation', () => {
  const specPath = path.join(
    skillRoot,
    'output',
    'design-md-hardening',
    'spec.yaml',
  );
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cocoloop-render-test-'));
  const result = renderSkillFromSpec(specPath, tempDir, { force: true });
  const validation = validatePlatformOutput(result.skillDir, result.renderedSpecPath);

  assert.deepEqual(validation.errors, []);
  assert.equal(validation.valid, true);
});


path: output/README.md
content: # Output 目录契约

## 目标

`output/` 用来保存每一轮正式收口后的可审查产物。
这些文件不是草稿缓存，也不是临时笔记。
它们是后续设计、构建、审查和回溯的直接输入。

## 目录命名

每一轮规则补充、流程加固、任务域研究或方案收口，都使用一个独立目录：

```text
output/<topic-slug>/
```

示例：

- `output/spec-schema-hardening/`
- `output/browser-automation-routing/`
- `output/preset-system-hardening/`

## 必选产物

每个目录至少包含：

- `research-summary.md`
- `reference-skill-analysis.md`
- `design-summary.md`
- `spec.md`
- `build-plan.md`

## 条件产物

在以下情况中继续补充：

- 进入结构化协议收口：补 `spec.yaml`，而且它应先于后续摘要产物形成
- 涉及协议审查：补 `spec-review.md`
- 涉及 benchmark：补 `benchmark-plan.md` 或评估文档

## 文件职责

- `research-summary.md`
  记录这轮研究核实了什么、得到什么稳定结论
- `reference-skill-analysis.md`
  记录本地拉取和对比过的 Skill、CLI、API/MCP、模板或参考方案
- `design-summary.md`
  记录设计决策、路线比较和最终收敛原因
- `spec.md`
  记录当前这轮要落实的统一要求
- `build-plan.md`
  记录后续文档或实现该怎么推进
- `spec.yaml`
  保存结构化协议实例；只要该轮已经进入统一协议层，它就是后续摘要的上游输入

## 生成顺序

建议固定顺序：

1. 先形成 `spec.yaml`（如果该轮已经进入统一协议层）
2. `research-summary.md`
3. `reference-skill-analysis.md`
4. `design-summary.md`
5. `spec.md`
6. `build-plan.md`
7. 需要时再补评估文档

## 使用规则

- 不要把正式结论只留在对话里
- 不要把不同主题混进同一个目录
- 设计阶段引用的结论，应能在对应 `output/` 目录中找到来源
- 如果当前研究覆盖还不充分，必须通过 `coverage_status` 和 `open_gaps` 表达


path: output/browser-automation-routing/build-plan.md
content: # Browser Automation Routing - 构建计划

## 目标

把浏览器自动化路线选择规则落实为可审查的正式文档产物。

## 构建目录

```text
browser-automation-routing/
├── research-summary.md
├── reference-skill-analysis.md
├── design-summary.md
├── spec.md
└── build-plan.md
```

## 构建动作

### 1. 更新正式需求

更新根级 `prd.md`，补入：

- 强需求浏览器自动化的路线比较要求
- `OpenCLI` 的优先推荐条件
- Browser Bridge 扩展安装说明要求

### 2. 更新产品需求文档

更新 `codex-prd`，把浏览器自动化路线比较写成正式需求基线。

### 3. 更新主 Skill 和阶段文档

至少更新：

- `SKILL.md`
- `ref/research.md`
- `ref/design.md`
- `sub-skills/brainstorm/SKILL.md`

### 4. 更新原子能力文档

至少更新：

- `atomic-capability/browser-access/index.md`
- 新增 `atomic-capability/browser-access/opencli-browser-bridge.md`

### 5. 生成输出产物

在 `output/browser-automation-routing/` 中生成：

- 研究摘要
- 参考 Skill 分析
- 设计摘要
- 统一 spec
- 构建计划


path: output/browser-automation-routing/design-summary.md
content: # Browser Automation Routing - 设计摘要

## 设计目标

把浏览器自动化从“遇到就选工具”改成“先比较路线，再做推荐”的正式流程。

## 采用路线

采用“调研阶段比较路线 + 设计阶段补齐本地分析 + 原子能力提供安装指南”的路线。

不采用“只在浏览器原子能力里补一句推荐”的轻量方案，因为那样无法把用户选择、安装前提和替代路径真正沉淀进主流程。

## 关键决策

### 决策 1

强需求浏览器自动化时，必须比较至少 2 条方向，再让用户选择。

### 决策 2

`OpenCLI` 不再只是可选例子，而是有条件的优先推荐路线。

条件是：

- 用户接受额外安装
- 目标业务已经落在 `OpenCLI` 支持面里

### 决策 3

`OpenCLI Browser Bridge` 安装说明必须作为正式交付的一部分保留，而不是只在对话里临时解释。

### 决策 4

`agent-browser` 与 `playwright-interactive` 继续保留为替代方向，分别承接独立浏览器自动化和深度调试场景。

## 设计结果

本次设计把落点拆成四层：

- `prd.md` 和 `codex-prd`：承接正式需求
- 主 Skill 与 `ref/`：承接调研和设计流程规则
- `atomic-capability/browser-access/`：承接路线决策与安装指南
- `output/browser-automation-routing/`：承接本次收口证据


path: output/browser-automation-routing/reference-skill-analysis.md
content: # Browser Automation Routing - 参考 Skill 本地分析

## 分析对象

- `/Users/tanshow/.codex/skills/opencli/SKILL.md`
- `/Users/tanshow/.codex/skills/playwright-interactive/SKILL.md`
- `/Users/tanshow/.codex/.tmp/plugins/plugins/vercel/skills/agent-browser/SKILL.md`
- `/Users/tanshow/.codex/.tmp/plugins/plugins/vercel/skills/agent-browser-verify/SKILL.md`

本次同时核对了官方公开来源：

- `jackwener/OpenCLI`
- `vercel-labs/agent-browser`

## 1. opencli

### 值得复用的能力

- 已有站点命令、真实浏览器控制、适配器生成三条路径
- 强调复用 Chrome 或 Chromium 已登录状态
- `opencli doctor` 提供统一验收入口

### 设计要点

- 更像统一执行面，而不是单次浏览器脚本
- 适合账号态业务和多站点统一命令入口
- 需要把 Browser Bridge 扩展安装说明交给用户

### 不适合直接当默认答案的情况

- 目标站点明显不在支持面里
- 用户不能接受扩展安装
- 任务更偏本地调试而不是网页登录态流程

## 2. agent-browser

### 值得复用的能力

- 独立浏览器自动化 CLI
- 截图、快照、等待、表单操作直接成型
- 适合本地页面验证和页面回归

### 设计要点

- 官方安装方式清楚，支持 `npm`、`brew`、`cargo`
- 首次使用必须执行 `agent-browser install`
- 更适合作为受控自动化执行面

### 不适合直接当默认答案的情况

- 用户必须复用当前个人浏览器登录态
- 业务已经被 `OpenCLI` 的现成命令和浏览器能力覆盖

## 3. playwright-interactive

### 值得复用的能力

- 持久 Playwright 会话
- 本地 Web 与 Electron 的功能 QA 和视觉 QA
- 改代码后继续复测的调试节奏

### 设计要点

- 依赖 `js_repl`
- 需要安装 `playwright`
- 环境准备成本明显更高

### 不适合直接当默认答案的情况

- 任务只是网页登录态自动化
- 用户要的是简单安装和直接上手

## 汇总结论

这三条路径各自解决的重点不同，所以 `skill-factory` 需要把它们作为路线比较对象。

推荐顺序如下：

1. 用户接受安装，且业务被 `OpenCLI` 覆盖时，优先 `OpenCLI`
2. 页面验证、结构化快照、独立浏览器自动化更重要时，优先 `agent-browser`
3. 本地调试、持久会话 QA、Electron 场景更重要时，优先 `playwright-interactive`


path: output/browser-automation-routing/research-summary.md
content: # Browser Automation Routing - 研究摘要

## 目标

把“强需求浏览器自动化时必须比较路线、`OpenCLI` 满足覆盖条件时优先、附带扩展安装说明”收口成正式研究结论。

## 本次核实对象

- 本地 Skill：`opencli`、`playwright-interactive`
- 本地临时插件 Skill：`agent-browser`、`agent-browser-verify`
- 官方仓库：`jackwener/OpenCLI`、`vercel-labs/agent-browser`
- 本机运行状态：`opencli --version`、`opencli doctor`、`agent-browser --version`

## 核实结果

### 1. `agent-browser` 是真实可用能力

- 官方仓库：`vercel-labs/agent-browser`
- CLI 包名：`agent-browser`
- 当前核实版本：`0.25.4`
- 官方安装方式：`npm install -g agent-browser`、`brew install agent-browser`、`cargo install agent-browser`
- 首次使用前置动作：`agent-browser install`

### 2. `OpenCLI` 的浏览器能力依赖 Browser Bridge 扩展

- 官方仓库：`jackwener/OpenCLI`
- npm 包名：`@jackwener/opencli`
- 当前核实版本：`1.7.4`
- 官方安装基线：安装 CLI、从 Releases 下载 `opencli-extension-v{version}.zip`、在 `chrome://extensions` 中 `Load unpacked`
- 官方验收命令：`opencli doctor`

### 3. 本机环境已具备两条执行面

- `opencli --version` 返回 `1.7.4`
- `opencli doctor` 返回 daemon 正常、extension 已连接、connectivity 正常
- `agent-browser --version` 返回 `0.25.4`

### 4. 三条路线的定位已经足够清楚

- `opencli`：优先解决复用当前 Chrome 或 Chromium 登录态、现成站点命令、浏览器内实时操作和适配器生成
- `agent-browser`：优先解决独立浏览器自动化、页面截图、结构化快照、表单与页面验证
- `playwright-interactive`：优先解决本地 Web 或 Electron 调试、持久会话 QA 和反复迭代验证

## 研究结论

后续 `skill-factory` 在处理强需求浏览器自动化任务时，需要把浏览器自动化视为路线选择问题，而不是单一工具推荐问题。

默认规则如下：

- 至少比较 2 条方向
- 如果用户接受额外安装，且业务被 `OpenCLI` 支持面覆盖，优先推荐 `OpenCLI`
- 推荐 `OpenCLI` 时，必须附带 Browser Bridge 扩展安装与 `opencli doctor` 验证说明
- 如果 `OpenCLI` 覆盖不足，继续保留 `agent-browser` 或 `playwright-interactive` 作为替代路径


path: output/browser-automation-routing/spec.md
content: # Browser Automation Routing - 统一 Spec

## 基本信息

- 名称：`browser-automation-routing`
- 目标：让 `skill-factory` 在强需求浏览器自动化任务里具备稳定的路线比较、推荐顺序和安装说明
- 当前阶段：文档与流程治理，不进入脚本实现

## 必须满足的要求

### 要求 1

如果任务强依赖浏览器自动化，调研阶段必须比较至少 2 条方向。

### 要求 2

比较时必须说明：

- 登录态复用
- 安装门槛
- 调试深度
- 稳定性
- 维护成本
- 替代路径

### 要求 3

如果用户接受额外安装，且业务被 `OpenCLI` 支持面覆盖，优先推荐 `OpenCLI`。

### 要求 4

推荐 `OpenCLI` 时，必须附带：

- Browser Bridge 扩展安装说明
- `opencli doctor` 验收方式

### 要求 5

如果 `OpenCLI` 覆盖不足，继续保留 `agent-browser` 或 `playwright-interactive` 作为替代路径。

## 输入

- `opencli` 本地 Skill
- `playwright-interactive` 本地 Skill
- `agent-browser` 本地 Skill
- `OpenCLI` 官方仓库
- `agent-browser` 官方仓库

## 输出

- 更新后的主 Skill 与阶段文档
- 更新后的浏览器原子能力文档
- 一份 `OpenCLI Browser Bridge` 安装指南
- 一组 `output/browser-automation-routing/` 构建产物

## 成功标准

- 浏览器自动化比较规则已经进入 `prd.md`
- 主 Skill 和调研、设计阶段文档已经写明路线比较与推荐顺序
- `OpenCLI` 安装指南已经进入仓库
- `output/` 下存在本次规则收口的完整样例产物


path: output/business-horizontal-expansion/build-plan.md
content: # Build Plan

## Completed

- 新增 6 个业务域预设。
- 更新预设索引。
- 更新调研路由说明。
- 更新 `spec-template.yaml` 的 `domain_supplements`。
- 更新 PRD 中的任务域地图、预设治理、协议说明、补充块示例和质量说明。

## Verification

- 运行 builder 单元测试。
- 运行 builder 回归测试。
- 检查 Python CLI 编译。
- 确认没有生成 `__pycache__` 或 `.pyc` 残留。

## Follow-Up

- 后续可以为每个业务域增加 `domain_supplements` 的完整字段校验。
- 后续可以让任务域路由器自动把自然语言 brief 映射到这 14 个域。
- 后续可以为内容、知识库、数据报告等业务域增加参考 Skill 搜索关键词包。


path: output/business-horizontal-expansion/design-summary.md
content: # Design Summary

## Business Layer

新增业务横向扩展层，作为第一层技术域和第二层系统域之外的高频业务场景集合。

## Routing

业务域可以作为 `primary_domain`。当它们只是补充素材、数据、风险、写回或视觉边界时，也可以进入 `peer_domains`。

## Gate Emphasis

业务域默认强化这些 gate：

- 受众和发布渠道
- 来源、引用、数据时间和事实核验
- 自动写回、自动发布和自动发送
- 隐私、凭据、客户数据和账号规则
- 视觉输出、品牌风格和可编辑性

## Spec Impact

`spec-template.yaml` 的 `domain_supplements` 已补齐新域入口，后续正式 spec 可以直接填对应补充块。


path: output/business-horizontal-expansion/reference-skill-analysis.md
content: # Reference Skill Analysis

## Local References

- `cocoloop-skill-factory/presets/index.md`
- `cocoloop-skill-factory/ref/research.md`
- `cocoloop-skill-factory/utils/template/spec-template.yaml`
- `codex-prd/skill-domain-landscape.md`
- `codex-prd/domain-supplement-examples.md`

## Reused Structure

- 每个新业务域沿用既有 preset 结构：
  - `domain_id`
  - `common_jobs`
  - `default_question_pack`
  - `recommended_execution_planes`
  - `risk_and_gates`
  - `default_outputs`

## Business Coverage

- 内容运营覆盖创作、SEO、多渠道素材和发布草稿。
- 知识库运营覆盖归档、wiki 化、索引和维护。
- 数据分析与报告覆盖数据清洗、指标、图表和报告。
- 客户支持运营覆盖工单、FAQ、回复草稿和反馈分析。
- 电商增长运营覆盖商品、活动、竞品、店铺和经营数据。
- 投研与财务分析覆盖公司、行业、市场、财报和风险报告。


path: output/business-horizontal-expansion/research-summary.md
content: # Research Summary

## Scope

本轮把 `skill-factory` 从技术交付与平台能力，继续扩展到更贴近日常经营的业务域。

## Added Domains

- `content_ops`
- `knowledge_base_ops`
- `data_analysis_reporting`
- `customer_support_ops`
- `ecommerce_growth_ops`
- `finance_investment_research`

## Stable Conclusions

- 这些业务域可以复用现有执行面：`Skill-only`、`Skill + CLI`、`Skill + API/MCP`、`Skill + CLI + API/MCP`。
- 业务域更需要明确受众、数据来源、写回权限、合规边界、人工审核和视觉输出 gate。
- 新业务域应进入 `primary_domain` 与 `peer_domains` 机制，而不是作为临时标签处理。
- `domain_supplements` 需要同步扩展，避免业务域只停留在文档描述中。


path: output/business-horizontal-expansion/spec.md
content: # Spec

## Required Presets

本轮必须新增并接入这些预设：

- `presets/content-ops.md`
- `presets/knowledge-base-ops.md`
- `presets/data-analysis-reporting.md`
- `presets/customer-support-ops.md`
- `presets/ecommerce-growth-ops.md`
- `presets/finance-investment-research.md`

## Required Routing Updates

- `presets/index.md` 必须列出业务横向扩展预设。
- `ref/research.md` 必须说明业务域可以作为主域或并列补充域。
- `utils/template/spec-template.yaml` 必须在 `domain_supplements` 中保留新域入口。

## Required PRD Updates

- `skill-domain-landscape.md` 必须把任务域集合扩展到 14 个。
- `preset-routing-and-governance.md` 必须说明业务横向扩展域。
- `spec-schema-and-protocol.md` 必须把新域写入任务域集合。
- `domain-supplement-examples.md` 必须给出新业务域补充块示例。
- `benchmark-and-quality.md` 必须补充业务域与 benchmark 的关系。


path: output/business-horizontal-expansion-2/build-plan.md
content: # Build Plan

## Completed

- 新增 6 个业务域预设。
- 更新预设索引。
- 更新调研路由说明。
- 更新 `spec-template.yaml` 的 `domain_supplements`。
- 更新 PRD 中的任务域地图、预设治理、协议说明、补充块示例和质量说明。

## Verification

- 运行 builder 单元测试。
- 运行 builder 回归测试。
- 检查 Python CLI 编译。
- 确认没有生成 `__pycache__` 或 `.pyc` 残留。

## Follow-Up

- 后续可以为新增业务域增加更严格的 `domain_supplements` 字段校验。
- 后续可以让任务域路由器自动把自然语言 brief 映射到 20 个正式任务域。
- 后续可以为销售、人力、教育、法务、产品研究和活动社群补充参考 Skill 搜索关键词包。


path: output/business-horizontal-expansion-2/design-summary.md
content: # Design Summary

## Business Layer Update

业务横向扩展层从 6 个域扩展到 12 个域，覆盖内容、知识、数据、客服、电商、投研、销售、人力、教育、法务、产品研究和活动社群。

## Routing

新增域进入正式任务域集合。调研阶段可以直接把它们作为 `primary_domain`，也可以在跨域需求中放入 `peer_domains`。

## Preset Contract

每个新增预设都保留固定结构：

- `domain_id`
- `common_jobs`
- `default_question_pack`
- `recommended_execution_planes`
- `risk_and_gates`
- `default_outputs`

## Spec Impact

`spec-template.yaml` 已加入新增域的 `domain_supplements` 空块。PRD 文档同步更新任务域地图、路由治理、协议说明、补充块示例和 benchmark 关系。


path: output/business-horizontal-expansion-2/reference-skill-analysis.md
content: # Reference Skill Analysis

## Reusable Patterns

本轮新增业务域主要复用以下能力模式：

- 调研阶段使用预设问题包减少重复追问
- 设计阶段把执行面拆成 `Skill-only`、`Skill + CLI`、`Skill + API/MCP`、`Skill + CLI + API/MCP`
- 生成阶段通过 `domain_supplements` 承接域独有结果要求
- 高风险操作通过人工确认 gate 收口

## Execution Plane Notes

`sales_crm_ops`、`hr_recruiting_ops` 和 `event_community_ops` 更常依赖 CRM、ATS、HRIS、日历、邮件、表格和社群工具。

`education_training_ops` 更常依赖知识库、文档、PPT、表格、LMS 和本地批量生成脚本。

`legal_contract_ops` 更常依赖文档比对、本地文件处理、合同系统、审批系统和任务系统。

`product_market_research` 更常依赖问卷、评论、竞品页面、产品分析、知识库和文档系统。

## Gate Notes

自动触达、候选人筛选、正式评分、法律结论、合同写回、公开发布和群发提醒都需要单独确认。涉及个人信息或敏感商业信息时，需要把脱敏、权限和审计要求写入设计摘要和构建计划。


path: output/business-horizontal-expansion-2/research-summary.md
content: # Research Summary

## Scope

本轮继续扩展业务横向预设，新增 6 个高频业务域：

- `sales_crm_ops`
- `hr_recruiting_ops`
- `education_training_ops`
- `legal_contract_ops`
- `product_market_research`
- `event_community_ops`

## Selection Basis

这些领域共同满足三个条件：

- 真实业务中高频出现
- 能复用现有 Skill、CLI、API 和 MCP 执行面
- 需要独立的问题包、风险 gate 和结果补充块

## Domain Fit

新增域补齐了业务经营链路中的销售、人力、学习、法务、产品研究和活动运营场景。它们可以独立作为 `primary_domain`，也可以和内容、知识库、数据分析、工作流集成、文档产物等域组合。

## Risk Notes

本轮新增域普遍涉及客户、候选人、学员、合同、用户反馈或报名信息。默认策略是先生成待审核产物，再通过明确 gate 处理写回、发送、发布、评分和正式结论。


path: output/business-horizontal-expansion-2/spec.md
content: # Spec

## Required Presets

本轮必须新增并接入这些预设：

- `presets/sales-crm-ops.md`
- `presets/hr-recruiting-ops.md`
- `presets/education-training-ops.md`
- `presets/legal-contract-ops.md`
- `presets/product-market-research.md`
- `presets/event-community-ops.md`

## Required Routing Updates

- `presets/index.md` 必须列出新增业务横向扩展预设。
- `ref/research.md` 必须说明新增业务域可以作为主域或并列补充域。
- `utils/template/spec-template.yaml` 必须在 `domain_supplements` 中保留新增域入口。

## Required PRD Updates

- `skill-domain-landscape.md` 必须把任务域集合扩展到 20 个。
- `preset-routing-and-governance.md` 必须补齐新增业务横向扩展域。
- `spec-schema-and-protocol.md` 必须把新增域写入任务域集合。
- `domain-supplement-examples.md` 必须给出新增业务域补充块示例。
- `benchmark-and-quality.md` 必须补充新增业务域与 benchmark 的关系。


path: output/clawhub-infographic-ppt-deep-dive/build-plan.md
content: # 后续推进计划

## 第一阶段

把这轮深拆结果沉淀成可复用参考，不直接动主流程代码。

1. 保留本轮拉下来的 6 个参考 Skill 源码目录。
2. 把本轮分析文档作为后续信息图和演示稿能力的正式引用来源。
3. 在后续需要时，从这里回溯模板、脚本和导出器细节。

## 第二阶段

把中间层方法沉淀进能力说明。

1. 为信息图补中间层协议草案
   - `analysis.md`
   - `structured-content.md`
2. 为演示稿补中间层协议草案
   - `presentation-brief.json`
   - `slide-outline.json`
   - `deck.md`
3. 明确 HTML 和 `pptx` 的导出边界

## 第三阶段

把这轮结论写回 `factory` 主资产。

1. 更新信息图原子能力
   - 增加分析驱动与模板驱动分流
2. 更新演示稿原子能力
   - 增加 brief / outline / render 三层说明
3. 视需要补充 preset
   - 长文信息图
   - 视觉笔记卡片
   - 商业 pitch deck
   - 文本转 HTML slides

## 第四阶段

把演示稿审校和导出链路做成正式规则。

1. 增加 factual validation 规则
2. 增加 overflow / text length 规则
3. 增加 HTML 到 PNG / PDF 的推荐导出路径
4. 增加 Markdown 到 `pptx` 的推荐导出路径


path: output/clawhub-infographic-ppt-deep-dive/design-summary.md
content: # 设计收束

## 目标

把这轮从 `clawhub` 拆出来的精华，收束成后续可并入 `skill-factory` 的稳定设计，而不是只停在“有哪些热门 Skill”。

## 设计方向

### 方向 1：信息图走“双轨”

信息图不该只做成一个泛化能力，应该分成两条轨道：

1. **分析驱动型信息图**
   - 适合文章、报告、研究总结、流程说明
   - 参考：`article-to-infographic` + `baoyu-infographic`
2. **模板驱动型知识卡片**
   - 适合知识卡片、传播图卡、单页海报
   - 参考：`visual-note-card`

原因很直接：

- 第一类任务需要先拆信息，再谈视觉组织。
- 第二类任务更适合先固定版式，再把内容压进去。

### 方向 2：演示稿走“三层”

演示稿生成建议拆成三层，而不是一个大一统 Skill：

1. **brief / interview layer**
   - 负责拿到真实素材、受众、speaker、angle、CTA
   - 参考：`ai-presentation-maker`
2. **outline layer**
   - 负责把输入压成 slide-by-slide 结构化中间层
   - 参考：`text-to-ppt`
3. **render / export layer**
   - 负责 HTML slides、Gamma、`pptx`、PDF 等不同导出
   - 参考：`ppt-maker` + `ai-presentation-maker`

这样后续不管输入来自访谈、现成文档，还是 Markdown，都可以接到同一套渲染层。

## 关键设计决策

### 1. 固定中间层

后续补能力时，必须要求信息图和演示稿都有显式中间层。

信息图建议至少有：

- `analysis.md`
- `structured-content.md`

演示稿建议至少有：

- `presentation-brief.json`
- `slide-outline.json`

### 2. 输出格式分离

不要把 HTML、PNG、`pptx`、PDF 混成一个“最终输出”。
应该把它们明确成不同导出器：

- `html_poster`
- `png_export`
- `html_slides`
- `pptx_export`
- `pdf_export`

### 3. 把事实校验写进演示稿能力

`ai-presentation-maker` 证明了：好的演示稿 Skill 不只是会排版，还要会审校。
后续 presentation 能力里建议内建：

- speculative claim check
- unverified number check
- projection caveat check
- overflow / length check

### 4. 把布局和风格分开

`baoyu-infographic` 的 layout × style 正交设计值得保留。
这意味着后续 spec 和能力目录里，信息图至少应拆出两个维度：

- `layout_family`
- `visual_style`

演示稿也建议类似处理：

- `slide_type`
- `theme`

## 推荐沉淀形态

### 原子能力层

建议后续把能力拆成下面这些原子块：

- `infographic-analysis`
- `infographic-card-template`
- `html-poster-export`
- `presentation-briefing`
- `slide-outline-generation`
- `html-slides-render`
- `pptx-render`
- `presentation-validation`

### 预设层

在现有 `document-artifacts` 和 `frontend-design` 预设里，后续可再细化：

- 信息图生成
  - 长文信息图
  - 视觉笔记卡片
- 演示稿生成
  - 商业 pitch deck
  - 研究总结 slides
  - 结构化 Markdown 转 `pptx`

### 输出层

这轮深拆之后，后续相关研究建议固定保留两类产物：

- `source-skills/`
  - 本地拉下来的 Skill 源码
- 分析文档
  - 方法拆解
  - 设计收口
  - 规格建议

## 收敛结论

后续接入 `factory` 时，最值得优先落实的不是“再搜更多 Skill”，而是先把这几条规则写稳：

1. 信息图和演示稿都必须先有中间层。
2. 信息图分分析驱动和模板驱动两轨。
3. 演示稿分 brief、outline、render 三层。
4. HTML 和 `pptx` 是两条平行输出链路。
5. 演示稿能力必须带 factual validation。


path: output/clawhub-infographic-ppt-deep-dive/reference-skill-analysis.md
content: # 参考 Skill 深拆

## 样本清单

| Skill | 主方向 | 中间层 | 最终产物 | 最值得借鉴 |
| --- | --- | --- | --- | --- |
| `article-to-infographic` | 长文转单页信息图 | outline + 分步确认 | HTML, PNG | 强确认流、强风格约束、单文件 HTML |
| `baoyu-infographic` | 专业信息图生成 | `analysis.md` + `structured-content.md` + prompt | 图片 | 分析框架、布局风格正交化 |
| `visual-note-card` | 知识卡片/视觉笔记 | 固定版式内容槽位 | HTML, PNG/JPEG | 模板骨架、海报级视觉语法 |
| `ppt-maker` | Markdown 转原生 `pptx` | Markdown DSL | `pptx` | 语法映射、表格转图表 |
| `ai-presentation-maker` | 访谈式 deck 生成 | interview brief + Markdown deck + JSON metadata | Markdown, HTML, Gamma, `pptx`, PDF | 访谈流、事实校验、speaker notes、多导出 |
| `text-to-ppt` | 文本转 HTML slides | JSON slide outline | HTML slides | 两阶段生成、并行逐页渲染 |

## 1. 如何拆解信息

### `article-to-infographic`

做法是先抽标题、副标题、关键统计、关键观点、引用、比较、时间顺序、自然分类和实体，然后按内容信号归类成时间线、数据面板、对比、流程、卡片网格或 editorial。

精华在于它不让 agent 直接写版面，而是先完成一个 outline 确认表，再单独确认布局、风格、插画和输出格式。这个流程能明显降低“内容还没想清楚，视觉已经定死”的问题。

### `baoyu-infographic`

这是本轮最强的内容拆解方案。它把前处理拆成：

- `analysis.md`
  - 主题
  - 学习目标
  - 目标受众
  - 内容类型
  - 复杂度
  - 原文数据点
  - layout × style 推荐
- `structured-content.md`
  - 标题
  - Overview
  - Learning Objectives
  - 分 section 的 key concept / content / visual element / text labels
  - Data points verbatim
  - Design instructions

这里最值钱的是两个原则：

- 先按 instructional design 做分析，再做视觉
- 所有事实、统计、引语都要求 verbatim 保留

这套结构非常适合沉淀到 `infographic-generation` 原子能力里。

### `visual-note-card`

它不追求通用内容分析，而是默认“把复杂内容压缩成一个固定 poster 语法”。
核心信息拆解方式是：

- 提炼一个 2 到 6 列的 framework
- 写一个强观点 thesis
- 左侧深色区讲故事、问题、转变
- 右侧浅色区讲编号洞察
- 底部做公式化收束

它的内容拆解更像“卡片编辑器思维”，适合知识卡片、传播图卡、海报型信息图。

### `ai-presentation-maker`

它把采集流程做成 6 个访谈阶段：

1. Subject
2. Audience
3. Speaker
4. Work
5. Angle
6. Resources & CTA

精华不是提问本身，而是每一段都明确规定了要捕获什么。
这样最后的 slide、speaker notes、CTA、export 都有稳定来源。

### `text-to-ppt`

它先把任意文本转成 JSON slide outline。这个中间层非常薄，但足够支撑并行生成：

- slide number
- type
- heading
- points / chartData
- layout
- notes

这非常适合 agent 环境里的并行 slide 生产。

### `ppt-maker`

它没有显式的分析文件，而是把 Markdown 语法本身当结构层：

- `#` 封面
- `##` 分页
- `###` 页内标题
- 列表、表格、代码块、引用块分别映射不同组件

优点是简单直接。缺点是前置分析能力弱，比较依赖上游先把 Markdown 写好。

## 2. 如何组织大纲

### 信息图

有 3 种成熟套路：

1. 先大纲，再视觉确认
   - 代表：`article-to-infographic`
   - 适合开放输入和高不确定性题材
2. 先学习目标，再 section 模板
   - 代表：`baoyu-infographic`
   - 适合系统性知识整理和高密度信息图
3. 先固定海报语法，再压内容
   - 代表：`visual-note-card`
   - 适合传播型视觉卡片

### 演示稿

有 3 种成熟套路：

1. 先访谈，再按 narrative arc 组 slide
   - 代表：`ai-presentation-maker`
2. 先出 JSON 大纲，再并行做 slide
   - 代表：`text-to-ppt`
3. 先写 Markdown，再直接渲染
   - 代表：`ppt-maker`

最强做法其实是把 1 和 2 结合：

- 先拿到访谈或 brief
- 再生成结构化大纲
- 最后再进渲染

## 3. 如何写文案

### 信息图文案

#### `baoyu-infographic`

文案纪律最强：

- 不新增事实
- 不改写统计
- 标题和标签要为视觉服务
- 先定义 viewer 要学会什么

这说明信息图文案不是普通摘要，而是“为了图形组织做语言压缩”。

#### `visual-note-card`

文案风格最鲜明：

- thesis 要有态度
- framework 名称要容易记
- left panel 更偏 narrative
- right panel 更偏 numbered insights
- bottom formula 要可传播

这套方法非常适合小红书卡片、知识海报、单页传播图。

#### `article-to-infographic`

它对文案的要求更多落在“不要 AI 套版味”和“设计必须有明确气质”。
这类 Skill 的文案策略偏保守，强在流程，不强在内容表达本身。

### 演示稿文案

#### `ai-presentation-maker`

文案最成熟的点有 4 个：

- 先选 angle，再写 deck
- slide 上只放事实，不放幻想
- 每页都带 speaker notes
- 显式规定 “What NOT to say”

它还做了 factual validation：

- speculative
- unverified number
- projection
- superlative

这一套特别适合商业汇报和对外演讲。

#### `text-to-ppt`

文案策略更偏工程化：

- 每页一个明确 layout
- 数据必须变成 chartData
- 列表必须符合短句规则
- notes 只做 slide 提示

#### `ppt-maker`

文案几乎全部托管给 Markdown 输入本身。
优点是清晰。缺点是很难自动生成高质量 narrative。

## 4. 如何排版

### 单页信息图 HTML

#### `visual-note-card`

这是本轮最值得复用的固定模板。
它的版式骨架很清楚：

- 顶部信息条
- 左标题 / 右 thesis
- framework row
- 深色故事面板 + 浅色洞察面板
- bottom highlight bar
- footer

优点：

- 模板稳定
- 视觉识别强
- 单页密度高
- 适合 HTML 到 PNG 的高保真导出

#### `article-to-infographic`

更像“版式原则库”而不是固定模板：

- timeline
- statistics dashboard
- comparison
- process flow
- listicle / card grid
- magazine / editorial

它强调：

- CSS Grid 做总布局
- 紧凑间距
- 不允许通用卡片堆叠感
- 需要 print media query

#### `baoyu-infographic`

它的排版方法是“layout × style”正交：

- `layout` 决定结构
- `style` 决定美学

这比简单的“theme”更适合信息图，因为信息结构本身就是版面的一部分。

### HTML slides

#### `text-to-ppt`

它给了一套很清楚的 slide DSL：

- centered
- bullets
- split
- grid
- timeline
- cards
- fullchart
- quote

再用固定 design system 约束：

- 16:9
- 无滚动
- 数字必须可视化
- 编号列表必须做 badge
- Font Awesome 图标
- Chart.js 模板

#### `ai-presentation-maker`

它把 HTML slides 分成两层：

- combined deck
- per-slide files

再配 11 种 slide type 和 4 套 theme。
这种做法对 factory 很有价值，因为它把“内容型 slide” 和 “舞台型 slide” 明确分开了。

### 原生 `pptx`

#### `ppt-maker`

它用 `pptxgenjs` 做得很实：

- cover / content / ending 三类页
- 主题色和图表色分离
- 列表、代码块、引用块、表格都有对应 renderer
- 图表页自动走“图表 + 辅助内容”双栏布局

这不是特别惊艳的版式系统，但非常实用。

#### `ai-presentation-maker`

它的 `pptx` 更像轻量导出：

- 从 Markdown 解析 slide
- 用 `python-pptx` 的基础 layout
- speaker notes 写入 notes slide

优点是通用。
缺点是视觉上明显弱于 HTML 路线。

## 5. 如何输出美观 HTML 和 `pptx`

### 美观 HTML 的共性

从这几套实现里，HTML 质量高的共同点是：

- 有稳定中间层，不是现编现排
- 主题、版式、组件有明确边界
- 把导出状态也算进模板
- 为 print / PNG / notes / keyboard navigation 单独设计

可直接吸收的做法：

- `visual-note-card`
  - 固定 poster 宽度和 section 语法
  - 浮动导出按钮
  - HTML 内建 PNG/JPEG 导出
- `text-to-ppt`
  - shell 模板 + slide div 拼装
  - JSON 大纲驱动逐页生成
- `ai-presentation-maker`
  - combined deck 和 per-slide 双模式
  - theme gallery + slide type gallery
  - notes 面板和打印链路

### 稳定 `pptx` 的共性

原生 `pptx` 质量更依赖 renderer：

- `ppt-maker` 适合从结构化 Markdown 快速出一个可编辑 deck
- `ai-presentation-maker` 适合把已有 Markdown deck 转成一个保守的 `pptx`

对 factory 最实用的结论是：

- 如果目标是“高颜值演示”，优先 HTML。
- 如果目标是“交付给 PowerPoint 用户继续改”，优先原生 `pptx`。
- 如果两个都要，最好先做 HTML 或 Markdown deck，再加单独导出器。

## 6. 最终可沉淀的方法库

### 信息图方法库

1. 先做内容分析
2. 产出结构化中间层
3. 分开选择 layout 和 style
4. 先确认 outline，再确认视觉
5. HTML 和 PNG 分开处理

### 演示稿方法库

1. 先拿 brief 或 interview
2. 产出 slide outline
3. 做 slide type 选择
4. 内容页和视觉页分开
5. HTML、Gamma、`pptx`、PDF 分开导出
6. 导出前做 factual validation 和 overflow 检查

## 7. 对 factory 的直接启发

后续如果要把这轮精华沉淀回 `factory`，最值得做的不是新增一个“会做 PPT/信息图”的笼统能力，而是补成几个稳定模块：

- 信息图分析模板
- 视觉卡片固定模板
- 演示稿访谈模板
- slide outline 协议
- HTML slide shell
- `pptx` adapter
- factual validation 规则
- HTML 到 PNG / PDF 的导出链路


path: output/clawhub-infographic-ppt-deep-dive/research-summary.md
content: # ClawHub 信息图与演示稿 Skill 深拆研究

## 研究范围

本轮研究聚焦 `clawhub` 中与信息图生成、视觉笔记卡片、演示稿生成、HTML slides、`pptx` 导出直接相关的 6 个 Skill：

- `article-to-infographic`
- `baoyu-infographic`
- `visual-note-card`
- `ppt-maker`
- `ai-presentation-maker`
- `text-to-ppt`

所有目标 Skill 都已经拉到本地：

- `output/clawhub-infographic-ppt-deep-dive/source-skills/`

研究方式分两层：

1. 先用 `clawhub search` 和 `clawhub inspect` 做候选筛选、文件清单确认和摘要核对。
2. 再把包体拉到本地，直接查看 `SKILL.md`、参考模板、导出脚本和 HTML/Python/JS 实现。

## 关键发现

### 1. 信息图方向已经分化出两条成熟路径

第一条是 `article-to-infographic` 和 `baoyu-infographic` 代表的“分析驱动型”。
这条路先拆信息，再选布局和风格，最后才进入生成。
重点是内容分类、结构映射、确认流程和 prompt 组装。

第二条是 `visual-note-card` 代表的“模板驱动型”。
这条路先固定版式，再把内容压进一个稳定 poster 骨架。
重点是卡片语法、固定分区、视觉密度和 HTML 到 PNG 的稳定输出。

### 2. 演示稿方向已经分化出三条成熟路径

第一条是 `ai-presentation-maker` 代表的“访谈驱动型”。
先通过 6 个阶段访谈拿到真实素材，再生成 deck、speaker notes 和多格式导出。

第二条是 `text-to-ppt` 代表的“计划先行型”。
先把输入转成 JSON 大纲，再并行生成单页 HTML，最后统一拼装。

第三条是 `ppt-maker` 代表的“语法驱动型”。
把 Markdown 当作 DSL，直接映射到 `pptxgenjs`，并从表格中自动识别图表。

### 3. HTML 产物的质量上限高于 `pptx`

本轮样本里，最精细的视觉控制都发生在 HTML 路线：

- `visual-note-card` 的固定海报模板
- `text-to-ppt` 的单页 slide shell
- `ai-presentation-maker` 的单页 HTML 和整 deck HTML

`pptx` 路线主要有两种：

- `ppt-maker` 直接生成原生 `.pptx`
- `ai-presentation-maker` 先存 Markdown，再通过 `python-pptx` 做轻量导出

从实现质量看：

- HTML 路线更适合美观、强风格、动画、导出 PDF、社交传播。
- `pptx` 路线更适合企业交付、可编辑性、兼容 PowerPoint 工作流。

### 4. 真正稳定的实现都把“内容结构”和“视觉渲染”分开了

这 6 个 Skill 虽然风格不同，但稳定做法很接近：

- 先拿源内容
- 再做结构化中间层
- 再做布局/风格/导出决策
- 最后才输出 HTML 或 `pptx`

中间层的形式不同：

- `analysis.md`
- `structured-content.md`
- JSON slide outline
- Markdown deck

但本质都是“先把信息组织清楚，再交给渲染器”。

### 5. 高质量输出都显式处理了导出和验证

不是所有 Skill 都只停在生成代码或生成 HTML：

- `visual-note-card` 自带 HTML 内部导出菜单和 Playwright PNG 截图脚本
- `article-to-infographic` 自带 Playwright 截图脚本，并在截图前强制 reveal/counter 到最终状态
- `ai-presentation-maker` 把 HTML、Gamma、`pptx`、PDF 做成分离的 export 路线
- `ppt-maker` 直接用 `pptxgenjs` 生成 `.pptx`

说明“产物交付链路”本身就是能力，不该被藏在实现细节里。

## 最值得吸收的精华

### 信息图

- `article-to-infographic`
  - 强确认流：大纲、布局、风格、插画、输出格式逐步确认
  - 强风格约束：明确禁止通用 AI 味设计
  - 强导出兜底：HTML 到 PNG 单独脚本化
- `baoyu-infographic`
  - 最好的信息分析框架
  - 最好的“layout × style”正交设计
  - 最好的结构化中间层模板
- `visual-note-card`
  - 最强固定模板
  - 最清楚的卡片级视觉语法
  - 最接近传播型图卡和知识卡片产物

### 演示稿

- `ai-presentation-maker`
  - 最好的访谈式信息采集
  - 最好的事实校验和 speaker notes 规则
  - 最完整的多导出路径
- `text-to-ppt`
  - 最好的“两阶段生成”纪律
  - 最清楚的并行 slide 生成协议
  - 最适合做 HTML slides 工厂
- `ppt-maker`
  - 最直接的 Markdown 到 `pptx` 语法映射
  - 最实用的表格转图表逻辑
  - 最适合做原生 `pptx` 快速生产

## 风险与异常

- `article-to-infographic` 在 `inspect` 中被标记为 `SUSPICIOUS`，本轮只做静态拆解，没有执行其脚本。
- `article-to-infographic/skill.json` 显示版本 `2.0.0`，但安装到本地的 `_meta.json` 仍是 `1.0.0`，存在元数据不一致。
- `ai-presentation-maker` 的设计非常完整，但包体里带了较长的营销内容，真正有价值的是中段的 interview、deck generation、export 和 template 部分。

## 结论

对 `skill-factory` 来说，这轮最重要的结论有 3 个：

1. 信息图和演示稿都应该先建“结构化中间层”，不要直接从原文跳最终产物。
2. HTML 和 `pptx` 应该被视为两条并列输出链路，各自有不同优势。
3. 后续能力沉淀不该只记录“能生成什么”，还要记录“如何收集信息、如何选结构、如何校验、如何导出”。


path: output/clawhub-infographic-ppt-deep-dive/source-skills/ai-presentation-maker/_meta.json
content: ownerId: kn70jf7w9b0nqceqq2zgc2b4qx80c681
slug: ai-presentation-maker
version: 1.0.0
publishedAt: 1771653850272

path: output/clawhub-infographic-ppt-deep-dive/source-skills/ai-presentation-maker/assets/presentation-helper.sh
content: #!/usr/bin/env bash
# presentation-helper.sh — Safe operations for AI Presentation Maker
# All user-provided input passes through code-enforced sanitization.
#
# Usage:
#   bash presentation-helper.sh <command> [args...]
#
# Commands:
#   init                        — Create workspace directories
#   save-meta                   — Validate and save presentation JSON from stdin
#   save-deck <pres_id>        — Save markdown deck from stdin
#   get-meta <pres_id>         — Read presentation metadata
#   get-deck <pres_id>         — Read markdown deck
#   list                        — List all presentations
#   delete <pres_id>           — Delete a presentation (metadata + deck)
#   archive <pres_id>          — Move to archive
#   duplicate <pres_id>        — Copy presentation with new ID
#   export-pdf <pres_id>       — Export to PDF via pandoc
#   export-pptx <pres_id>      — Export to PPTX via python-pptx
#   sanitize-string <string>   — Echo sanitized version of input

set -euo pipefail

PRES_DIR="${HOME}/workspace/presentations"
DECKS_DIR="${PRES_DIR}/decks"
EXPORTS_DIR="${PRES_DIR}/exports"
ARCHIVE_DIR="${PRES_DIR}/archive"

# ──────────────────────────────────────────────
# SANITIZATION FUNCTIONS
# ──────────────────────────────────────────────

sanitize_string() {
  local input="$1"
  local max_len="${2:-200}"
  local clean
  clean=$(printf '%s' "$input" | tr -d '`$\\!(){}|;&<>#' | sed "s/['\"]//g")
  printf '%s' "$clean" | head -c "$max_len"
}

sanitize_filename() {
  local input="$1"
  local clean
  clean=$(printf '%s' "$input" | tr -cd 'a-zA-Z0-9_-' | head -c 50)
  printf '%s' "$clean"
}

validate_path() {
  local target="$1"
  local resolved
  resolved=$(realpath -m "$target" 2>/dev/null || echo "$target")
  if [[ "$resolved" != "${PRES_DIR}"* ]]; then
    echo "ERROR: Path traversal blocked — must be within ${PRES_DIR}" >&2
    return 1
  fi
  printf '%s' "$resolved"
}

validate_json() {
  local file="$1"
  if command -v jq &>/dev/null; then
    jq empty "$file" 2>/dev/null || { echo "ERROR: Invalid JSON in $file" >&2; return 1; }
  else
    local first last
    first=$(head -c 1 "$file")
    last=$(tail -c 2 "$file" | head -c 1)
    if [[ "$first" != "{" ]] || [[ "$last" != "}" ]]; then
      echo "ERROR: File does not appear to be valid JSON" >&2
      return 1
    fi
  fi
}

# ──────────────────────────────────────────────
# COMMANDS
# ──────────────────────────────────────────────

cmd_init() {
  mkdir -p "${DECKS_DIR}" "${EXPORTS_DIR}" "${ARCHIVE_DIR}"
  echo "✅ Workspace created at ${PRES_DIR}"
}

cmd_save_meta() {
  local tmp="/tmp/pres_meta_tmp.json"
  cat > "$tmp"
  
  validate_json "$tmp"
  
  local pres_id
  if command -v jq &>/dev/null; then
    pres_id=$(jq -r '.presentation_id // empty' "$tmp")
  else
    pres_id=$(grep -o '"presentation_id": *"[^"]*"' "$tmp" | head -1 | cut -d'"' -f4)
  fi
  
  [[ -z "$pres_id" ]] && { echo "ERROR: No presentation_id in JSON" >&2; rm -f "$tmp"; return 1; }
  
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local target="${DECKS_DIR}/${safe_id}.json"
  
  validate_path "$target" >/dev/null
  
  cp "$tmp" "$target"
  rm -f "$tmp"
  echo "✅ Metadata saved: ${safe_id}.json"
}

cmd_save_deck() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local target="${DECKS_DIR}/${safe_id}.md"
  
  validate_path "$target" >/dev/null
  
  # Read markdown from stdin
  cat > "$target"
  
  if [[ -s "$target" ]]; then
    local slide_count
    slide_count=$(grep -c "^## " "$target" 2>/dev/null || echo 0)
    echo "✅ Deck saved: ${safe_id}.md (${slide_count} slides)"
  else
    echo "ERROR: Deck file is empty" >&2
    return 1
  fi
}

cmd_get_meta() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local target="${DECKS_DIR}/${safe_id}.json"
  
  validate_path "$target" >/dev/null
  [[ -f "$target" ]] || { echo "ERROR: Presentation not found: ${safe_id}" >&2; return 1; }
  
  cat "$target"
}

cmd_get_deck() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local target="${DECKS_DIR}/${safe_id}.md"
  
  validate_path "$target" >/dev/null
  [[ -f "$target" ]] || { echo "ERROR: Deck not found: ${safe_id}" >&2; return 1; }
  
  cat "$target"
}

cmd_list() {
  if [[ ! -d "$DECKS_DIR" ]] || [[ -z "$(ls -A "$DECKS_DIR"/*.json 2>/dev/null)" ]]; then
    echo "No presentations found."
    return 0
  fi
  
  for f in "${DECKS_DIR}"/*.json; do
    [[ -f "$f" ]] || continue
    
    local name created slide_count angle
    if command -v jq &>/dev/null; then
      name=$(jq -r '.name // "Untitled"' "$f")
      created=$(jq -r '.created // "Unknown"' "$f")
      angle=$(jq -r '.angle.title // "No angle"' "$f")
      slide_count=$(jq -r '.slides | length // 0' "$f")
    else
      name=$(grep -o '"name": *"[^"]*"' "$f" | head -1 | cut -d'"' -f4)
      created=$(grep -o '"created": *"[^"]*"' "$f" | head -1 | cut -d'"' -f4)
      angle=$(grep -o '"title": *"[^"]*"' "$f" | head -1 | cut -d'"' -f4)
      slide_count="?"
    fi
    
    local pres_id
    pres_id=$(basename "$f" .json)
    printf '%-12s %-30s %-25s %s slides  %s\n' "$pres_id" "${name:0:29}" "${angle:0:24}" "$slide_count" "${created:0:10}"
  done
}

cmd_delete() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  
  local meta="${DECKS_DIR}/${safe_id}.json"
  local deck="${DECKS_DIR}/${safe_id}.md"
  
  validate_path "$meta" >/dev/null
  validate_path "$deck" >/dev/null
  
  local deleted=0
  [[ -f "$meta" ]] && { rm -f "$meta"; deleted=$((deleted + 1)); }
  [[ -f "$deck" ]] && { rm -f "$deck"; deleted=$((deleted + 1)); }
  
  # Also remove exports
  rm -f "${EXPORTS_DIR}/${safe_id}".*  2>/dev/null
  
  if [[ $deleted -gt 0 ]]; then
    echo "✅ Deleted: ${safe_id} (${deleted} files)"
  else
    echo "ERROR: Presentation not found: ${safe_id}" >&2
    return 1
  fi
}

cmd_archive() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  
  local meta_src="${DECKS_DIR}/${safe_id}.json"
  local deck_src="${DECKS_DIR}/${safe_id}.md"
  
  validate_path "$meta_src" >/dev/null
  
  local moved=0
  [[ -f "$meta_src" ]] && { mv "$meta_src" "${ARCHIVE_DIR}/"; moved=$((moved + 1)); }
  [[ -f "$deck_src" ]] && { mv "$deck_src" "${ARCHIVE_DIR}/"; moved=$((moved + 1)); }
  
  if [[ $moved -gt 0 ]]; then
    echo "✅ Archived: ${safe_id}"
  else
    echo "ERROR: Presentation not found: ${safe_id}" >&2
    return 1
  fi
}

cmd_duplicate() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  
  local meta_src="${DECKS_DIR}/${safe_id}.json"
  local deck_src="${DECKS_DIR}/${safe_id}.md"
  
  [[ -f "$meta_src" ]] || { echo "ERROR: Presentation not found: ${safe_id}" >&2; return 1; }
  
  # Generate new ID
  local new_id
  new_id="pres_$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' ')"
  local safe_new_id
  safe_new_id=$(sanitize_filename "$new_id")
  
  local meta_dst="${DECKS_DIR}/${safe_new_id}.json"
  local deck_dst="${DECKS_DIR}/${safe_new_id}.md"
  
  # Copy metadata with new ID
  if command -v jq &>/dev/null; then
    jq --arg id "$safe_new_id" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
      '.presentation_id = $id | .created = $now | .updated = $now | .name = .name + " (copy)"' \
      "$meta_src" > "$meta_dst"
  else
    sed "s/\"presentation_id\": *\"[^\"]*\"/\"presentation_id\": \"${safe_new_id}\"/" "$meta_src" > "$meta_dst"
  fi
  
  # Copy deck
  [[ -f "$deck_src" ]] && cp "$deck_src" "$deck_dst"
  
  echo "✅ Duplicated: ${safe_id} → ${safe_new_id}"
  echo "New ID: ${safe_new_id}"
}

cmd_export_pdf() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local deck="${DECKS_DIR}/${safe_id}.md"
  local output="${EXPORTS_DIR}/${safe_id}.pdf"
  
  validate_path "$deck" >/dev/null
  [[ -f "$deck" ]] || { echo "ERROR: Deck not found: ${safe_id}" >&2; return 1; }
  
  command -v pandoc &>/dev/null || { echo "ERROR: pandoc not installed. Install with your package manager." >&2; return 1; }
  
  pandoc "$deck" -o "$output" --pdf-engine=xelatex 2>/dev/null || \
  pandoc "$deck" -o "$output" 2>/dev/null || \
  { echo "ERROR: PDF export failed. Try: pandoc $deck -o $output" >&2; return 1; }
  
  echo "✅ PDF exported: ${output}"
}

cmd_export_pptx() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local deck="${DECKS_DIR}/${safe_id}.md"
  local meta="${DECKS_DIR}/${safe_id}.json"
  local output="${EXPORTS_DIR}/${safe_id}.pptx"
  
  validate_path "$deck" >/dev/null
  [[ -f "$deck" ]] || { echo "ERROR: Deck not found: ${safe_id}" >&2; return 1; }
  
  python3 -c "import pptx" 2>/dev/null || { echo "ERROR: python-pptx not installed. Install with: pip install python-pptx" >&2; return 1; }
  
  # Generate PPTX using the export script
  local script_dir
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  
  local export_script="${script_dir}/../references/export-pptx.py"
  [[ -f "$export_script" ]] || export_script="${script_dir}/export-pptx.py"
  
  if [[ -f "$export_script" ]]; then
    python3 "$export_script" "$deck" "$output" "$meta"
  else
    echo "ERROR: export-pptx.py not found" >&2
    return 1
  fi
  
  echo "✅ PPTX exported: ${output}"
}

cmd_export_html() {
  local pres_id="$1"
  local theme="${2:-gradient}"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local deck="${DECKS_DIR}/${safe_id}.md"
  local meta="${DECKS_DIR}/${safe_id}.json"
  local output="${EXPORTS_DIR}/${safe_id}.html"
  
  validate_path "$deck" >/dev/null
  [[ -f "$deck" ]] || { echo "ERROR: Deck not found: ${safe_id}" >&2; return 1; }
  
  which python3 &>/dev/null || { echo "ERROR: python3 not found" >&2; return 1; }
  
  local script_dir
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  
  local export_script="${script_dir}/../references/export-html-slides.py"
  [[ -f "$export_script" ]] || export_script="${script_dir}/export-html-slides.py"
  
  if [[ -f "$export_script" ]]; then
    local meta_arg=""
    [[ -f "$meta" ]] && meta_arg="$meta"
    python3 "$export_script" "$deck" "$output" $meta_arg --theme "$theme"
  else
    echo "ERROR: export-html-slides.py not found" >&2
    return 1
  fi
}

cmd_export_gamma() {
  local pres_id="$1"
  local safe_id
  safe_id=$(sanitize_filename "$pres_id")
  local deck="${DECKS_DIR}/${safe_id}.md"
  local output="${EXPORTS_DIR}/${safe_id}_gamma.md"
  
  validate_path "$deck" >/dev/null
  [[ -f "$deck" ]] || { echo "ERROR: Deck not found: ${safe_id}" >&2; return 1; }
  
  local script_dir
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  
  local export_script="${script_dir}/../references/export-gamma.sh"
  [[ -f "$export_script" ]] || export_script="${script_dir}/export-gamma.sh"
  
  if [[ -f "$export_script" ]]; then
    bash "$export_script" "$deck" "$output"
  else
    echo "ERROR: export-gamma.sh not found" >&2
    return 1
  fi
}

cmd_sanitize_string() {
  sanitize_string "$1" "${2:-200}"
}

# ──────────────────────────────────────────────
# MAIN DISPATCH
# ──────────────────────────────────────────────

case "${1:-}" in
  init)             cmd_init ;;
  save-meta)        cmd_save_meta ;;
  save-deck)        cmd_save_deck "${2:?ERROR: pres_id required}" ;;
  get-meta)         cmd_get_meta "${2:?ERROR: pres_id required}" ;;
  get-deck)         cmd_get_deck "${2:?ERROR: pres_id required}" ;;
  list)             cmd_list ;;
  delete)           cmd_delete "${2:?ERROR: pres_id required}" ;;
  archive)          cmd_archive "${2:?ERROR: pres_id required}" ;;
  duplicate)        cmd_duplicate "${2:?ERROR: pres_id required}" ;;
  export-pdf)       cmd_export_pdf "${2:?ERROR: pres_id required}" ;;
  export-pptx)      cmd_export_pptx "${2:?ERROR: pres_id required}" ;;
  export-html)      cmd_export_html "${2:?ERROR: pres_id required}" "${3:-gradient}" ;;
  export-gamma)     cmd_export_gamma "${2:?ERROR: pres_id required}" ;;
  sanitize-string)  cmd_sanitize_string "${2:?ERROR: string required}" "${3:-200}" ;;
  *)
    echo "presentation-helper.sh — Safe operations for AI Presentation Maker"
    echo ""
    echo "Commands:"
    echo "  init                        Create workspace"
    echo "  save-meta                   Save presentation JSON from stdin"
    echo "  save-deck <pres_id>         Save markdown deck from stdin"
    echo "  get-meta <pres_id>          Read presentation metadata"
    echo "  get-deck <pres_id>          Read markdown deck"
    echo "  list                        List all presentations"
    echo "  delete <pres_id>            Delete presentation"
    echo "  archive <pres_id>           Move to archive"
    echo "  duplicate <pres_id>         Copy with new ID"
    echo "  export-pdf <pres_id>        Export to PDF"
    echo "  export-pptx <pres_id>       Export to PPTX"
    echo "  export-html <pres_id> [theme]  Export to HTML slides (dark/light/gradient)"
    echo "  export-gamma <pres_id>      Export for Gamma.app import"
    echo "  sanitize-string <str> [max] Sanitize input"
    ;;
esac


path: output/clawhub-infographic-ppt-deep-dive/source-skills/ai-presentation-maker/references/export-gamma.sh
content: #!/usr/bin/env bash
# export-gamma.sh — Convert a Presentation Maker deck to Gamma.app import format
#
# Gamma imports markdown with these rules:
# - Each ## heading becomes a new card (slide)
# - Supports bold, italic, lists, tables, blockquotes
# - Speaker notes are NOT imported — strip them
# - No HTML — pure markdown only
# - Less formatting = better auto-styling by Gamma
# - Horizontal rules (---) separate cards
# - Images via ![alt](url) if hosted
#
# Usage:
#   bash export-gamma.sh <deck.md> <output.md>

set -euo pipefail

if [[ $# -lt 2 ]]; then
  echo "Usage: bash export-gamma.sh <deck.md> <output.md>"
  exit 1
fi

INPUT="$1"
OUTPUT="$2"

[[ -f "$INPUT" ]] || { echo "ERROR: File not found: $INPUT" >&2; exit 1; }

# Process the markdown:
# 1. Remove speaker notes blocks (everything between "**Speaker Notes" and the next --- or ##)
# 2. Remove lines starting with > (blockquote notes)
# 3. Remove "**What to say:**", "**What NOT to say:**", "**Timing:**", "**Visual aids:**" lines
# 4. Remove empty lines that result from stripping
# 5. Clean up multiple blank lines
# 6. Remove "Slide N: " prefix from headings
# 7. Keep everything else (clean content)

awk '
  BEGIN { in_notes = 0; skip_next_rule = 0 }

  # Detect speaker notes start
  /\*\*Speaker Notes/ { in_notes = 1; next }
  /\*\*What to say/ { in_notes = 1; next }
  /\*\*Say:\*\*/ { in_notes = 1; next }
  /\*\*What NOT to say/ { in_notes = 1; next }
  /\*\*Don.t say/ { in_notes = 1; next }
  /\*\*Timing:\*\*/ { next }
  /\*\*Visual aids:\*\*/ { next }

  # End notes on heading or horizontal rule
  /^## / { in_notes = 0 }
  /^---$/ {
    if (in_notes) { in_notes = 0; next }
    # Print separator for Gamma card breaks
    print "---"
    next
  }

  # Skip if in notes
  in_notes { next }

  # Skip blockquote lines that are speaker note style
  /^> \*\*Speaker/ { next }
  /^> \*\*Say/ { next }
  /^> \*\*Don/ { next }
  /^> \*\*Timing/ { next }
  /^> \*\*Visual/ { next }

  # Clean "Slide N: " prefix from headings
  /^## Slide [0-9]+: / {
    sub(/^## Slide [0-9]+: /, "## ")
    print
    next
  }

  # Remove the "Generated by" footer
  /Generated by AI Presentation Maker/ { next }
  /Facts, not fantasies/ { next }

  # Print everything else
  { print }
' "$INPUT" | \
# Clean up multiple blank lines (max 2 consecutive)
awk '
  /^$/ { blank++; if (blank <= 2) print; next }
  { blank = 0; print }
' > "$OUTPUT"

# Count cards (## headings)
CARDS=$(grep -c "^## " "$OUTPUT" 2>/dev/null || echo 0)
TITLE=$(head -1 "$OUTPUT" | sed 's/^# //')

echo "✅ Gamma export ready: $OUTPUT"
echo "   Title: $TITLE"
echo "   Cards: $CARDS"
echo ""
echo "📋 To import into Gamma:"
echo "   1. Go to gamma.app → New → Import"
echo "   2. Choose 'Paste text' or upload the .md file"
echo "   3. Gamma will auto-style each ## heading as a card"
echo "   4. Choose a theme and hit 'Generate'"


path: output/clawhub-infographic-ppt-deep-dive/source-skills/ai-presentation-maker/references/export-html-slides.py
content: #!/usr/bin/env python3
"""
export-html-slides.py — Convert a Presentation Maker markdown deck to beautiful HTML slides.
Zero dependencies beyond Python 3 standard library.

Features:
- Full-screen slide presentation in browser
- Keyboard navigation (arrow keys, space)
- Print-optimized (each slide = one page)
- Speaker notes toggle (press 'N')
- Progress bar
- Dark/light theme toggle
- Self-contained single HTML file

Usage:
  python3 export-html-slides.py <deck.md> <output.html> [metadata.json] [--theme dark|light|gradient]
"""

import sys
import re
import json
from pathlib import Path
from html import escape


def parse_markdown_slides(md_path: str) -> dict:
    """Parse markdown into title + slides with content and speaker notes."""
    with open(md_path, 'r', encoding='utf-8') as f:
        content = f.read()

    title_text = ""
    subtitle_text = ""
    slides = []
    current_slide = None
    in_notes = False

    for line in content.split('\n'):
        # Main title
        if line.startswith('# ') and not line.startswith('## '):
            title_text = line[2:].strip()
            continue

        # Subtitle line (italic)
        if line.startswith('*') and not title_text == "" and not slides:
            subtitle_text = line.strip('* ')
            continue

        # New slide
        if line.startswith('## '):
            if current_slide:
                slides.append(current_slide)
            title = line[3:].strip()
            title = re.sub(r'^Slide \d+:\s*', '', title)
            current_slide = {
                'title': title,
                'content': [],
                'notes': [],
                'raw_content': ''
            }
            in_notes = False
            continue

        if not current_slide:
            continue

        # Detect speaker notes section
        if '**Speaker Notes' in line or '**What to say' in line or '**Say:**' in line:
            in_notes = True
            continue

        # Horizontal rule resets notes
        if line.strip() == '---':
            in_notes = False
            continue

        if in_notes:
            clean = line.strip()
            clean = re.sub(r'^>\s*', '', clean)
            if clean:
                current_slide['notes'].append(clean)
        else:
            stripped = line.strip()
            if stripped and stripped != '---':
                current_slide['content'].append(line)

    if current_slide:
        slides.append(current_slide)

    return {
        'title': title_text,
        'subtitle': subtitle_text,
        'slides': slides
    }


def md_to_html(lines: list) -> str:
    """Convert markdown lines to HTML."""
    html_parts = []
    in_list = False
    in_table = False
    table_rows = []

    for line in lines:
        stripped = line.strip()
        if not stripped:
            if in_list:
                html_parts.append('</ul>')
                in_list = False
            continue

        # Table detection
        if '|' in stripped and stripped.startswith('|'):
            cells = [c.strip() for c in stripped.split('|')[1:-1]]
            if all(re.match(r'^[-:]+$', c) for c in cells):
                continue  # Skip separator row
            if not in_table:
                in_table = True
                html_parts.append('<table>')
                tag = 'th'
            else:
                tag = 'td'
            row = ''.join(f'<{tag}>{escape(c)}</{tag}>' for c in cells)
            html_parts.append(f'<tr>{row}</tr>')
            continue
        elif in_table:
            html_parts.append('</table>')
            in_table = False

        # List items
        if re.match(r'^[-*•]\s', stripped):
            if not in_list:
                html_parts.append('<ul>')
                in_list = True
            item = re.sub(r'^[-*•]\s+', '', stripped)
            item = apply_inline(item)
            html_parts.append(f'<li>{item}</li>')
            continue

        # Numbered list
        if re.match(r'^\d+\.\s', stripped):
            item = re.sub(r'^\d+\.\s+', '', stripped)
            item = apply_inline(item)
            html_parts.append(f'<li>{item}</li>')
            continue

        if in_list:
            html_parts.append('</ul>')
            in_list = False

        # Blockquote
        if stripped.startswith('>'):
            text = apply_inline(stripped.lstrip('> '))
            html_parts.append(f'<blockquote>{text}</blockquote>')
            continue

        # Regular paragraph
        text = apply_inline(stripped)
        html_parts.append(f'<p>{text}</p>')

    if in_list:
        html_parts.append('</ul>')
    if in_table:
        html_parts.append('</table>')

    return '\n'.join(html_parts)


def apply_inline(text: str) -> str:
    """Apply inline markdown formatting."""
    # Bold
    text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
    # Italic
    text = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', text)
    # Code
    text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
    # Links
    text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
    return text


def generate_html(data: dict, metadata: dict = None, theme: str = "gradient") -> str:
    """Generate a complete self-contained HTML presentation."""

    slides_html = []

    # Title slide
    speaker_info = ""
    if metadata:
        sp = metadata.get('speaker', {})
        parts = [sp.get('name', ''), sp.get('title', '')]
        speaker_info = ' — '.join(p for p in parts if p)

    slides_html.append(f"""
    <section class="slide title-slide">
      <div class="slide-content">
        <h1>{escape(data['title'])}</h1>
        <p class="subtitle">{escape(speaker_info or data['subtitle'])}</p>
      </div>
    </section>""")

    # Content slides
    for i, slide in enumerate(data['slides']):
        content_html = md_to_html(slide['content'])
        notes_html = ''
        if slide['notes']:
            notes_items = '\n'.join(f'<li>{apply_inline(escape(n))}</li>' for n in slide['notes'])
            notes_html = f'<div class="speaker-notes"><h4>Speaker Notes</h4><ul>{notes_items}</ul></div>'

        slides_html.append(f"""
    <section class="slide" data-index="{i + 1}">
      <div class="slide-content">
        <h2>{escape(slide['title'])}</h2>
        <div class="body">{content_html}</div>
      </div>
      {notes_html}
    </section>""")

    all_slides = '\n'.join(slides_html)
    total = len(data['slides']) + 1

    # Theme colors
    themes = {
        "dark": {
            "bg": "#1a1a2e",
            "card": "#16213e",
            "text": "#e8e8e8",
            "accent": "#4a90d9",
            "highlight": "#e94560",
            "title_bg": "linear-gradient(135deg, #0f3460 0%, #1a1a2e 100%)",
        },
        "light": {
            "bg": "#f5f5f5",
            "card": "#ffffff",
            "text": "#2d2d2d",
            "accent": "#4a90d9",
            "highlight": "#e74c3c",
            "title_bg": "linear-gradient(135deg, #4a90d9 0%, #357abd 100%)",
        },
        "gradient": {
            "bg": "#0f0c29",
            "card": "rgba(255,255,255,0.05)",
            "text": "#f0f0f0",
            "accent": "#302b63",
            "highlight": "#24c6dc",
            "title_bg": "linear-gradient(135deg, #24c6dc 0%, #514a9d 50%, #302b63 100%)",
        },
        "terminal": {
            "bg": "#1A1A1A",
            "card": "#252525",
            "text": "#FFFFFF",
            "accent": "#333333",
            "highlight": "#00E676",
            "title_bg": "linear-gradient(135deg, #252525 0%, #1A1A1A 100%)",
        },
        "executive": {
            "bg": "#0D1B2A",
            "card": "#1B2838",
            "text": "#FFFFFF",
            "accent": "#2D3F52",
            "highlight": "#FFB700",
            "title_bg": "linear-gradient(135deg, #1B2838 0%, #0D1B2A 100%)",
        },
        "spark": {
            "bg": "#0f0c29",
            "card": "rgba(255,255,255,0.05)",
            "text": "#f0f0f0",
            "accent": "#302b63",
            "highlight": "#24c6dc",
            "title_bg": "linear-gradient(135deg, #24c6dc 0%, #514a9d 50%, #302b63 100%)",
        },
        "clean": {
            "bg": "#FFFFFF",
            "card": "#F8F8F8",
            "text": "#1A1A1A",
            "accent": "#E0E0E0",
            "highlight": "#E63946",
            "title_bg": "linear-gradient(135deg, #E63946 0%, #C5303C 100%)",
        },
    }
    t = themes.get(theme, themes["gradient"])

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(data['title'])}</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;800&display=swap');

  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}

  :root {{
    --bg: {t['bg']};
    --card: {t['card']};
    --text: {t['text']};
    --accent: {t['accent']};
    --highlight: {t['highlight']};
    --title-bg: {t['title_bg']};
  }}

  body {{
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
    background: var(--bg);
    color: var(--text);
    overflow: hidden;
    height: 100vh;
    width: 100vw;
  }}

  /* ── SLIDE CONTAINER ── */
  .deck {{ position: relative; width: 100vw; height: 100vh; }}

  .slide {{
    position: absolute;
    top: 0; left: 0;
    width: 100vw;
    height: 100vh;
    display: none;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    padding: 5vh 8vw;
    opacity: 0;
    transition: opacity 0.4s ease;
  }}
  .slide.active {{ display: flex; opacity: 1; }}

  .slide-content {{
    max-width: 1100px;
    width: 100%;
  }}

  /* ── TITLE SLIDE ── */
  .title-slide {{
    background: var(--title-bg);
    text-align: center;
  }}
  .title-slide h1 {{
    font-size: clamp(2.5rem, 5vw, 4.5rem);
    font-weight: 800;
    letter-spacing: -0.02em;
    line-height: 1.1;
    margin-bottom: 1rem;
    color: #fff;
  }}
  .title-slide .subtitle {{
    font-size: clamp(1rem, 2vw, 1.5rem);
    font-weight: 300;
    opacity: 0.85;
    color: #fff;
  }}

  /* ── CONTENT SLIDES ── */
  .slide:not(.title-slide) {{
    background: var(--bg);
  }}
  h2 {{
    font-size: clamp(1.8rem, 3.5vw, 3rem);
    font-weight: 700;
    margin-bottom: 2rem;
    color: var(--highlight);
    letter-spacing: -0.01em;
  }}
  .body {{ font-size: clamp(1rem, 1.8vw, 1.35rem); line-height: 1.7; }}
  .body p {{ margin-bottom: 1rem; }}
  .body ul {{ margin: 1rem 0 1rem 1.5rem; }}
  .body li {{ margin-bottom: 0.6rem; }}
  .body strong {{ color: var(--highlight); font-weight: 600; }}
  .body table {{
    width: 100%;
    border-collapse: collapse;
    margin: 1.5rem 0;
  }}
  .body th, .body td {{
    padding: 0.75rem 1rem;
    text-align: left;
    border-bottom: 1px solid rgba(255,255,255,0.1);
  }}
  .body th {{ font-weight: 600; color: var(--highlight); }}
  .body blockquote {{
    border-left: 4px solid var(--highlight);
    padding: 0.5rem 1.5rem;
    margin: 1rem 0;
    opacity: 0.9;
    font-style: italic;
  }}
  .body code {{
    background: rgba(255,255,255,0.1);
    padding: 0.15em 0.4em;
    border-radius: 4px;
    font-size: 0.9em;
  }}
  .body a {{ color: var(--highlight); text-decoration: underline; }}

  /* ── SPEAKER NOTES ── */
  .speaker-notes {{
    display: none;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: rgba(0,0,0,0.92);
    color: #ccc;
    padding: 1.5rem 3rem;
    font-size: 0.85rem;
    max-height: 35vh;
    overflow-y: auto;
    border-top: 2px solid var(--highlight);
    z-index: 100;
  }}
  .speaker-notes h4 {{ color: var(--highlight); margin-bottom: 0.5rem; }}
  .speaker-notes ul {{ margin-left: 1.2rem; }}
  .speaker-notes li {{ margin-bottom: 0.3rem; }}
  body.show-notes .slide.active .speaker-notes {{ display: block; }}

  /* ── PROGRESS BAR ── */
  .progress {{
    position: fixed;
    bottom: 0;
    left: 0;
    height: 4px;
    background: var(--highlight);
    transition: width 0.3s ease;
    z-index: 200;
  }}

  /* ── CONTROLS ── */
  .controls {{
    position: fixed;
    top: 1.5rem;
    right: 2rem;
    display: flex;
    gap: 0.5rem;
    z-index: 200;
    opacity: 0.3;
    transition: opacity 0.3s;
  }}
  .controls:hover {{ opacity: 1; }}
  .controls button {{
    background: rgba(255,255,255,0.1);
    border: 1px solid rgba(255,255,255,0.2);
    color: var(--text);
    padding: 0.4rem 0.8rem;
    border-radius: 6px;
    cursor: pointer;
    font-size: 0.75rem;
    font-family: inherit;
  }}
  .controls button:hover {{ background: rgba(255,255,255,0.2); }}
  .controls button.active {{ background: var(--highlight); color: #fff; }}

  .slide-counter {{
    position: fixed;
    bottom: 1.5rem;
    right: 2rem;
    font-size: 0.8rem;
    opacity: 0.4;
    z-index: 200;
  }}

  /* ── PRINT STYLES ── */
  @media print {{
    body {{ overflow: visible; background: #fff; color: #222; }}
    .deck {{ position: static; }}
    .slide {{
      position: relative !important;
      display: flex !important;
      opacity: 1 !important;
      page-break-after: always;
      height: 100vh;
      width: 100vw;
      background: #fff !important;
      color: #222 !important;
    }}
    .title-slide {{
      background: var(--title-bg) !important;
      color: #fff !important;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }}
    h2 {{ color: #333 !important; }}
    .body strong {{ color: #333 !important; font-weight: 700; }}
    .body th {{ color: #333 !important; }}
    .body td, .body th {{ border-bottom-color: #ddd !important; }}
    .speaker-notes {{ display: none !important; }}
    .controls, .slide-counter, .progress {{ display: none !important; }}
  }}

  /* ── RESPONSIVE ── */
  @media (max-width: 768px) {{
    .slide {{ padding: 3vh 5vw; }}
    .controls {{ top: 0.5rem; right: 0.5rem; }}
  }}
</style>
</head>
<body>

<div class="controls">
  <button onclick="toggleNotes()" id="notesBtn" title="Toggle speaker notes (N)">Notes</button>
  <button onclick="window.print()" title="Print / Save as PDF">Print</button>
</div>

<div class="deck" id="deck">
{all_slides}
</div>

<div class="progress" id="progress"></div>
<div class="slide-counter" id="counter"></div>

<script>
  let current = 0;
  const slides = document.querySelectorAll('.slide');
  const total = slides.length;

  function showSlide(n) {{
    slides.forEach(s => s.classList.remove('active'));
    current = Math.max(0, Math.min(n, total - 1));
    slides[current].classList.add('active');
    document.getElementById('progress').style.width = ((current + 1) / total * 100) + '%';
    document.getElementById('counter').textContent = (current + 1) + ' / ' + total;
  }}

  function next() {{ showSlide(current + 1); }}
  function prev() {{ showSlide(current - 1); }}

  function toggleNotes() {{
    document.body.classList.toggle('show-notes');
    document.getElementById('notesBtn').classList.toggle('active');
  }}

  document.addEventListener('keydown', e => {{
    if (e.key === 'ArrowRight' || e.key === ' ') {{ e.preventDefault(); next(); }}
    else if (e.key === 'ArrowLeft') {{ e.preventDefault(); prev(); }}
    else if (e.key === 'n' || e.key === 'N') {{ toggleNotes(); }}
    else if (e.key === 'Home') {{ showSlide(0); }}
    else if (e.key === 'End') {{ showSlide(total - 1); }}
  }});

  // Touch support
  let touchStartX = 0;
  document.addEventListener('touchstart', e => {{ touchStartX = e.touches[0].clientX; }});
  document.addEventListener('touchend', e => {{
    const diff = e.changedTouches[0].clientX - touchStartX;
    if (Math.abs(diff) > 50) {{ diff > 0 ? prev() : next(); }}
  }});

  showSlide(0);
</script>

</body>
</html>"""


def main():
    if len(sys.argv) < 3:
        print("Usage: python3 export-html-slides.py <deck.md> <output.html> [metadata.json] [--theme dark|light|gradient]")
        sys.exit(1)

    md_path = sys.argv[1]
    output_path = sys.argv[2]
    meta_path = None
    theme = "gradient"

    for i, arg in enumerate(sys.argv[3:], 3):
        if arg == '--theme' and i + 1 < len(sys.argv):
            theme = sys.argv[i + 1]
        elif not arg.startswith('--') and arg.endswith('.json'):
            meta_path = arg

    metadata = None
    if meta_path and Path(meta_path).exists():
        with open(meta_path, 'r', encoding='utf-8') as f:
            metadata = json.load(f)

    data = parse_markdown_slides(md_path)
    if not data['slides']:
        print("ERROR: No slides found in markdown", file=sys.stderr)
        sys.exit(1)

    html = generate_html(data, metadata, theme)
    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(html)

    print(f"✅ HTML slides saved: {output_path} ({len(data['slides']) + 1} slides, theme: {theme})")


if __name__ == '__main__':
    main()


path: output/clawhub-infographic-ppt-deep-dive/source-skills/ai-presentation-maker/references/export-pptx.py
content: #!/usr/bin/env python3
"""
export-pptx.py — Convert a Presentation Maker markdown deck to PPTX.
Requires: python-pptx (pip install python-pptx)

Usage:
  python3 export-pptx.py <deck.md> <output.pptx> [metadata.json]
"""

import sys
import re
import json
from pathlib import Path

try:
    from pptx import Presentation
    from pptx.util import Inches, Pt
    from pptx.enum.text import PP_ALIGN
except ImportError:
    print("ERROR: python-pptx not installed. Run: pip install python-pptx", file=sys.stderr)
    sys.exit(1)


def parse_markdown_slides(md_path: str) -> list:
    """Parse markdown file into slide objects."""
    with open(md_path, 'r', encoding='utf-8') as f:
        content = f.read()

    slides = []
    current_slide = None

    for line in content.split('\n'):
        # Title slide (# heading)
        if line.startswith('# ') and not line.startswith('## '):
            if current_slide:
                slides.append(current_slide)
            current_slide = {
                'type': 'title',
                'title': line[2:].strip(),
                'content': [],
                'speaker_notes': []
            }
        # Regular slide (## heading)
        elif line.startswith('## '):
            if current_slide:
                slides.append(current_slide)
            # Extract slide title (remove "Slide N: " prefix if present)
            title = line[3:].strip()
            title = re.sub(r'^Slide \d+:\s*', '', title)
            current_slide = {
                'type': 'content',
                'title': title,
                'content': [],
                'speaker_notes': []
            }
        # Speaker notes marker
        elif current_slide and ('**Speaker Notes' in line or '**Say:**' in line or '**What to say' in line):
            current_slide['_in_notes'] = True
        elif current_slide and line.startswith('---'):
            if current_slide.get('_in_notes'):
                current_slide.pop('_in_notes', None)
        elif current_slide:
            if current_slide.get('_in_notes'):
                # Clean markdown formatting for notes
                clean = re.sub(r'\*\*([^*]+)\*\*', r'\1', line.strip())
                clean = re.sub(r'^>\s*', '', clean)
                if clean:
                    current_slide['speaker_notes'].append(clean)
            else:
                clean = line.strip()
                if clean and not clean.startswith('>'):
                    current_slide['content'].append(clean)

    if current_slide:
        slides.append(current_slide)

    # Clean up internal flags
    for s in slides:
        s.pop('_in_notes', None)

    return slides


def create_pptx(slides: list, output_path: str, metadata: dict = None):
    """Generate a PPTX file from parsed slides."""
    prs = Presentation()

    # Set slide dimensions (widescreen 16:9)
    prs.slide_width = Inches(13.333)
    prs.slide_height = Inches(7.5)

    for slide_data in slides:
        if slide_data['type'] == 'title':
            # Title slide
            layout = prs.slide_layouts[0]  # Title Slide layout
            slide = prs.slides.add_slide(layout)

            title = slide.shapes.title
            if title:
                title.text = slide_data['title']
                title.text_frame.paragraphs[0].font.size = Pt(36)

            # Add subtitle from metadata
            if metadata and slide.placeholders:
                for ph in slide.placeholders:
                    if ph.placeholder_format.idx == 1:  # Subtitle
                        speaker = metadata.get('speaker', {})
                        subtitle_parts = []
                        if speaker.get('name'):
                            subtitle_parts.append(speaker['name'])
                        if speaker.get('title'):
                            subtitle_parts.append(speaker['title'])
                        ph.text = ' — '.join(subtitle_parts)
                        break

        else:
            # Content slide
            layout = prs.slide_layouts[1]  # Title and Content layout
            slide = prs.slides.add_slide(layout)

            # Set title
            title = slide.shapes.title
            if title:
                title.text = slide_data['title']
                title.text_frame.paragraphs[0].font.size = Pt(28)

            # Set content
            content_text = '\n'.join(slide_data['content'])
            # Clean markdown formatting
            content_text = re.sub(r'\*\*([^*]+)\*\*', r'\1', content_text)
            content_text = re.sub(r'\*([^*]+)\*', r'\1', content_text)
            content_text = re.sub(r'^- ', '• ', content_text, flags=re.MULTILINE)
            content_text = re.sub(r'^\d+\. ', '• ', content_text, flags=re.MULTILINE)

            for ph in slide.placeholders:
                if ph.placeholder_format.idx == 1:  # Content area
                    ph.text = content_text
                    for para in ph.text_frame.paragraphs:
                        para.font.size = Pt(18)
                    break

        # Add speaker notes
        if slide_data['speaker_notes']:
            notes_text = '\n'.join(slide_data['speaker_notes'])
            notes_slide = slide.notes_slide
            notes_slide.notes_text_frame.text = notes_text

    prs.save(output_path)
    print(f"✅ PPTX saved: {output_path} ({len(slides)} slides)")


def main():
    if len(sys.argv) < 3:
        print("Usage: python3 export-pptx.py <deck.md> <output.pptx> [metadata.json]")
        sys.exit(1)

    md_path = sys.argv[1]
    output_path = sys.argv[2]
    meta_path = sys.argv[3] if len(sys.argv) > 3 else None

    metadata = None
    if meta_path and Path(meta_path).exists():
        with open(meta_path, 'r', encoding='utf-8') as f:
            metadata = json.load(f)

    slides = parse_markdown_slides(md_path)
    if not slides:
        print("ERROR: No slides found in markdown file", file=sys.stderr)
        sys.exit(1)

    create_pptx(slides, output_path, metadata)


if __name__ == '__main__':
    main()


path: output/clawhub-infographic-ppt-deep-dive/source-skills/article-to-infographic/Skills/article-to-infographic-README.md
content: # article-to-infographic v2.0.0 发布包

## 文件说明
- `article-to-infographic-v2.0.0.tar.gz` - Skill 完整发布包

## 内容清单
- SKILL.md - 主技能文档（三步确认流程）
- skill.json - 元数据（版本 2.0.0）
- references/style-presets.md - 6种视觉样式预设
- references/illustrations-guide.md - 插图集成指南
- scripts/html_to_png.py - PNG 导出脚本

## ClawHub 发布命令
```bash
clawhub publish article-to-infographic-v2.0.0 \
  --slug "article-to-infographic" \
  --name "Article to Infographic" \
  --version "2.0.0" \
  --changelog "3-step workflow, Premium Magazine style, CJK optimization"
```

## 特性亮点
✅ 强制三步确认流程（大纲/风格/输出）
✅ 容错PNG导出（4种方法回退）
✅ 中文字体优化
✅ 6种视觉风格（含Premium Magazine高级风格）

生成时间: 2026-02-24
作者: 龙虾 × 麦虾


path: output/clawhub-infographic-ppt-deep-dive/source-skills/article-to-infographic/_meta.json
content: ownerId: kn79n26pppbktn95ky47wvxdyx81r2hb
slug: article-to-infographic
version: 1.0.0
publishedAt: 1771936036689

path: output/clawhub-infographic-ppt-deep-dive/source-skills/baoyu-infographic/_meta.json
content: ownerId: kn7csrrndw79hpke5d0gsnx93d82k67r
slug: baoyu-infographic
version: 1.103.1
publishedAt: 1776096926931

path: output/clawhub-infographic-ppt-deep-dive/source-skills/baoyu-infographic/references/layouts/circular-flow.md
content: # circular-flow

Cyclic process showing continuous or recurring steps.

## Structure

- Circular arrangement
- Steps around the circle
- Arrows showing direction
- No clear start/end (continuous)
- Center can hold main concept

## Best For

- Recurring processes
- Feedback loops
- Lifecycle stages
- Continuous improvement
- Natural cycles

## Visual Elements

- Circle or ring shape
- Directional arrows
- Step nodes evenly spaced
- Icons per step
- Optional center element

## Text Placement

- Title at top
- Step labels at each node
- Brief descriptions near nodes
- Center concept if applicable
- Cycle name

## Recommended Pairings

- `cartoon-hand-drawn`: Friendly cycles
- `corporate-memphis`: Business processes
- `subway-map`: Transit-style cycles


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)