Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-09-03 10:30:35 +08:00
parent 30aa782719
commit 117d615855
32 changed files with 680 additions and 111 deletions
@@ -2,8 +2,8 @@
"sourceId": "etunel-role-collaboration",
"repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git",
"ref": "main",
"commit": "af8596a8a251c6a783b2f44a45047bebb2a7d68a",
"commit": "76cb0846d42e24bec29fbb3e3a39ea7f4338424e",
"adapter": "claude-skill",
"sourcePath": "etunel-role-collaboration",
"syncedAt": "2026-09-02T16:00:01Z"
"syncedAt": "2026-09-03T02:28:39Z"
}
@@ -13,7 +13,7 @@ description: "用于 Etunel 多 Agent 项目的角色识别、Hub 中介、依
- 当前角色、项目名称、项目模式、流程基线和所处阶段;
- 任务目标、唯一主责角色、协作角色、上游依赖和完成时间;
- 已有资料、要求产出的文件或成果包、最低内容、证据、完成条件和下游用途。
- 已有资料、本阶段完成约定、要求产出的文件或成果包、最低内容、证据、完成条件和下游用途。
Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 role ID、member ID、session ID、消息 ID 等运行时字段,也不要在任务正文中制造不存在的 ID。Hub 需要识别角色时调用 `mcp__etunel__etunel_list_roles`,用 `role` 路由、用 `display_name` 面向人显示。
@@ -21,17 +21,18 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
1. 项目Hub是成员 Agent 之间唯一的跨角色中介。成员只把任务结果、问题或阻塞交 Hub,由 Hub 定向路由;成员之间不直接通信。
2. 正常阶段顺序为:业务需求、产品定义、方案设计、项目规划、软硬件实现、测试验证、业务验收、发布结项。阶段按有效流程基线和依赖推进。
3. 依赖已满足的任务可以组成任务波次。一个 Etunel 调用只面向一个接收角色;同一角色的紧密相关工作可以组成一个任务包
4. 每项任务只有一个主责角色和一个可独立判断的结果。Etunel 的角色绑定决定实际接收会话,不另造“主责成员 ID”
5. Hub 必须给足执行所需的背景、有效输入、边界、产出和完成条件,但不默认广播完整计划、全部资料或私有对话
6. 正式执行任务必须形成指定文件或连贯成果包,任务要求与实际产出一致。纯信息查询、确认或决定不虚构空文件
7. 成员收到任务后先用简明中文与当前负责人对齐;形成成果后完成专业自审并取得负责人确认,再通过 Etunel 正式返回
8. 面向负责人、角色或钉钉的内容使用自然、简洁的中文。机器字段和枚举只留在工具参数或机器记录中,不把内部术语和 ID 倾倒给人
9. Hub 只校验角色、结构、版本、证据、确认状态、跨角色冲突、依赖和门禁,不替专业角色补写内容或作出专业结论
10. Hub 按阶段保存角色提交的文件,维护项目进度、阶段记录和成果索引。必要成果未保存且不可访问时,不宣布正式阶段交接完成
11. 流程可按 Hub 负责人明确决定跳转、回退或暂缓;Hub 必须记录缺失成果、原因、影响、风险和补齐安排,且不得把未满足门禁写成已通过。正式交付仍需补齐适用成果与必要确认;模拟流程不能冒充真实交付
12. Etunel 运行时负责队列、投递、重试、去重、会话授权和文件传输;本 Skill 不重复设计这些软件机制
13. 当前 Hook、工具 schema、角色契约、`etunel_list_roles` 返回和已确认流程基线高于本 Skill 的通用说明;不存在的工具、角色、字段或文件不得伪造
3. 每个阶段开始时,Hub 在该阶段记录中简要区分“本阶段必须完成、后续阶段处理、当前不需要”。只有直接影响本阶段成果或决定的问题可以阻塞;门禁满足并留档后立即启动下一阶段,由新阶段按需取得自己的输入
4. 依赖已满足的任务可以组成任务波次。一个 Etunel 调用只面向一个接收角色;同一角色的紧密相关工作可以组成一个任务包
5. 每项任务只有一个主责角色和一个可独立判断的结果。Etunel 的角色绑定决定实际接收会话,不另造“主责成员 ID”
6. Hub 必须给足执行所需的背景、有效输入、边界、产出和完成条件,但不默认广播完整计划、全部资料或私有对话
7. 正式执行任务必须形成指定文件或连贯成果包,任务要求与实际产出一致。纯信息查询、确认或决定不虚构空文件
8. 成员收到任务后先用简明中文与当前负责人对齐;形成成果后完成专业自审并取得负责人确认,再通过 Etunel 正式返回
9. 面向负责人、角色或钉钉的内容使用自然、简洁的中文。机器字段和枚举只留在工具参数或机器记录中,不把内部术语和 ID 倾倒给人
10. Hub 只校验角色、结构、版本、证据、确认状态、跨角色冲突、依赖和门禁,不替专业角色补写内容或作出专业结论
11. Hub 按阶段保存角色提交的文件,维护项目进度、阶段记录和成果索引。必要成果未保存且不可访问时,不宣布正式阶段交接完成
12. 流程可按 Hub 负责人明确决定跳转、回退或暂缓;Hub 必须记录缺失成果、原因、影响、风险和补齐安排,且不得把未满足门禁写成已通过。正式交付仍需补齐适用成果与必要确认;模拟流程不能冒充真实交付
13. Etunel 运行时负责队列、投递、重试、去重、会话授权和文件传输;本 Skill 不重复设计这些软件机制
14. 当前 Hook、工具 schema、角色契约、`etunel_list_roles` 返回和已确认流程基线高于本 Skill 的通用说明;不存在的工具、角色、字段或文件不得伪造。
## 渐进式路由
@@ -60,7 +61,7 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
## 每项任务的控制循环
1. 从运行时确认当前角色、项目、阶段、目标、输入、产出和负责人,不向人核对内部 ID。
1. 从运行时确认当前角色、项目、阶段、本阶段完成约定、目标、输入、产出和负责人,不向人核对内部 ID。
2. 用简明中文一次说明要做什么、已有资料、还缺什么、会产出什么以及需要负责人确认什么。
3. 信息足够后执行本角色工作;缺少跨角色输入时先问当前负责人,再向 Hub 提交一组聚合问题。
4. 形成指定文件或成果包,完成专业自审并取得负责人确认。
@@ -21,7 +21,7 @@ Hub 定义任务产出、成员提交结果、判断状态或校验门禁时读
## 先定义产出要求
正式执行任务派发时明确:
正式执行任务的产出和完成条件只来自“本阶段必须完成”的内容。后续阶段处理和当前不需要的内容可以登记,但不能自动成为当前成果、任务完成条件或门禁。“本阶段完成约定”不能替代正式成果的适用性判断、受控跳转或《成果豁免申请》。派发时明确:
1. 正式成果或成果包名称及唯一主责角色;
2. 每项最低内容、适用子项和协作边界;
@@ -75,6 +75,8 @@ Hub 定义任务产出、成员提交结果、判断状态或校验门禁时读
负责人确认表示当前负责人实际查看本次结果并确认可以作为该角色正式提交。至少记录确认人、时间、文件或版本、范围和附带条件。AI 不得自行声称已获确认;模拟项目只确认模拟材料的使用和流程推进,不证明模拟值真实。
负责人确认随对应文件、版本、适用范围和附带条件继续有效。Hub 归档、移动或引用同一成果不需要重新确认;只有内容或版本发生实质变化、当前用途超出原确认范围、负责人发生变化、确认证据缺失,或出现与原结论冲突的新事实时,才重新确认。另一角色对该成果作新的专业评估时,仍须取得该角色自己的负责人确认。
## 不适用、延期与成果豁免
- 不适用内容写“不适用”,机器记录可以使用 `NA`,并说明条件和依据。
@@ -94,7 +96,7 @@ Hub 定义任务产出、成员提交结果、判断状态或校验门禁时读
角色可以提交职责内有价值的额外文件,说明它与原任务的关系、对完成结论的影响、下游用途以及新增依赖、风险和维护责任。
Hub 可以将其纳入成果索引。若改变范围、接口、角色责任、基线、排期、正式成果或验收,先走《变更申请》,不能静默生效。
Hub 可以将其作为支持材料保存。额外文件不会自动成为正式成果、增加当前完成条件、制造新阻塞或激活下游依赖。若确实需要改变范围、接口、角色责任、基线、排期、正式成果或验收,先由有权负责人批准并走《变更申请》,不能静默生效。
## 事实、判断和证据
@@ -106,10 +108,16 @@ Hub 可以将其纳入成果索引。若改变范围、接口、角色责任、
代码、固件、硬件、配置、设计、计划或报告给出足以识别对象的版本或引用,并说明上游输入、产生或验证版本、被替代旧版本、与接口、板卡、BOM、ECO、构建或环境的匹配关系和下游约束。
新结果不能静默覆盖旧基线。Hub 按 [项目文件与进度留档](project-files-and-progress.md) 保存文件、更新成果索引和阶段记录;正式变更保留旧版本、新版本和生效范围
文档类正式成果只有在范围、行为、验收、接口、专业决定或下游行动发生实质变化时,才创建新版本并按需要重新确认。只更新任务状态、转交说明或排版,生成内容相同的重复副本,或补充不改变结论的过程说明,不升正式版本;有复盘价值时写入阶段记录。不得为排版或状态更新覆盖已经接受的文件。代码、固件、硬件、配置和构建物继续遵循本领域版本规则,实际对象或构建发生变化时必须能区分版本
新结果不能静默覆盖旧基线。Hub 按 [项目文件与进度留档](project-files-and-progress.md) 保存文件、更新成果索引和阶段记录;正式变更保留旧版本、新版本和生效范围。成果索引为每项成果明确一个当前有效版本,下游默认只接收该版本;历史版本和过程材料不参与当前完成判断,除非被明确恢复为有效版本。
## Hub 校验边界
Hub 检查文件存在、最低结构、来源角色、版本、证据引用、自审、负责人确认、冲突、依赖、状态层、留档和门禁;不替专业角色判断内容是否充分。专业冲突定向交拥有决定权的角色。
成果只有在 Hub 实际收到并能访问文件、确认来源角色正确、核对任务与输入基线、检查必要版本、证据、自审和负责人确认、完成阶段归档和成果索引更新,并明确作出“接受”结论后,才成为当前有效成果。成员说明已完成、附件刚送达或消息状态变化都不能单独替代接受。
成果已经接受后,后续 Etunel 任务记录异常不撤销该成果,也不要求角色重复制作或重复提交;Hub 单独记录并说明运行时异常。文件未收到、不可访问或未经校验时,不能用任务或消息状态推进项目。本 Skill 不处理 Etunel 的队列、重试或去重逻辑。
向下游只传递任务需要的已登记成果、版本、约束、风险和证据引用,不复制完整聊天或全部资料。
@@ -99,6 +99,8 @@ Hub 对每条入站消息选择:
消息结算、排队和送达不代表任务、成果、阶段或项目完成。
成果已经按 [成果与完成判定](artifacts-and-evidence.md) 被 Hub 接受并归档后,后续任务记录异常不撤销成果,也不要求成员重复制作或提交;Hub 单独记录并说明运行时异常。尚未实际收到或校验成果时,不能用消息状态继续推进。本文件不为此设计额外重试、去重或队列逻辑。
## 附件
直接使用当前消息正文和内嵌内容。仅当消息明确列出附件且为当前任务必要输入时,调用 `mcp__etunel__etunel_receive_message` 获取所需附件。Hub 收到成果文件后按 [项目文件与进度留档](project-files-and-progress.md) 归档;不要为探测队列或重复确认而读取。
@@ -6,7 +6,7 @@
成员按以下顺序处理:
1. 检查任务和本会话已有的已确认资料
1. 依次检查任务附带的当前有效成果、项目已确认规则、本会话已有资料和其他可访问的项目文件
2. 一次向当前负责人列清缺什么、用途、影响和建议来源;
3. 负责人能回答时记录来源、时间、范围和条件后继续;
4. 负责人不能回答时,提醒其线下寻找能解决问题的人;
@@ -42,6 +42,8 @@ Hub 有登记答案时直接补足;没有时只向实际信息所有者角色
正式阻塞用简明中文说明原因、已完成成果、负责人沟通、线下协调、影响、需要谁采取什么动作以及恢复条件。
缺口只有在直接导致本阶段成果无法形成、完成条件无法判断、主责角色无法作出当前决定,或构成当前必须处理的安全、合规、不可逆损失及已批准承诺风险时,才是当前阻塞。提出方必须指明受影响的本阶段必需项和“为什么现在必须解决”;仅供后续阶段使用的资料登记为后续依赖,不阻塞当前阶段。
- Hub 评估关键路径;不受影响的任务继续。
- Hub 主动提醒当前主责和实际关联角色,提供问题、证据、影响、原流程位置、所需回应和期限;不机械通知全体角色。
- 能由一个角色解决时只派该角色;多个独立问题可以形成波次。
@@ -49,6 +51,8 @@ Hub 有登记答案时直接补足;没有时只向实际信息所有者角色
- 需要用户、业务、项目发起人或重大争议决定时,Hub 负责人介入。
- 阻塞解除后由 Hub 派发新的恢复或返工任务。
新发现的安全、合规或可能造成不可逆损失的风险尚待确认时,Hub 可以先暂停受影响工作,并立即请求对应专业负责人和 Hub 负责人判断;无关任务继续。确认后把结论写入本阶段完成约定和项目记录,再由有权方决定解决、延期、接受风险或恢复工作,不能让未经核实的担忧永久阻塞整个项目。
Etunel 投递、成员返回、会话或消息链路实际中断是流程阻塞;正常排队和等待不是阻塞。阻塞发生和解除都更新项目进度与阶段记录。
## 复杂线下会议与会议决定记录
@@ -20,12 +20,19 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
2. 使用当前 Etunel 绑定,不从文档示例、聊天或记忆猜测角色与会话信息。
3. 调用 `mcp__etunel__etunel_list_roles` 读取实际角色,用 `role` 路由,用 `display_name` 面向人显示;不要求负责人核对内部 ID。
4. 按 [项目文件与进度留档](project-files-and-progress.md) 建立目录、成员清单、项目概览和初始进度。
5. 在途项目继续使用已登记流程基线,除非取得明确迁移决定
6. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认
5. 从《项目概览》读取当前项目已经确认的特殊规则;只登记有权负责人明确确认的内容,并持续沿用到同一决定权人修改或取消,不在每项任务中重复询问
6. 在途项目继续使用已登记流程基线,除非取得明确迁移决定
7. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认。
## 启动阶段并明确完成约定
进入每个阶段时,Hub 在该阶段的《阶段记录》中写下简短的“本阶段完成约定”:本阶段必须完成、后续阶段处理、当前不需要。Hub 根据当前流程基线、有效上游成果和项目已确认规则整理,并放入承担当前必需项的主责角色在该阶段收到的首个任务;各角色只需在与本角色负责人正常对齐时确认或纠正自己的部分,不另发全员确认任务。涉及业务范围、客户承诺或组织授权时,再取得业务确认。
新问题只有在直接导致本阶段成果无法形成、完成条件无法判断、主责角色无法作出当前决定,或构成当前必须处理的安全、合规、不可逆损失及已批准承诺风险时,才可成为本阶段阻塞。提出问题的角色必须说明对应的当前必需项和“为什么现在必须解决”;仅供后续使用的资料登记为后续依赖。
## 规划任务与波次
按 [项目生命周期](project-lifecycle.md) 找出依赖已满足、当前确有必要的任务:
本阶段完成约定和 [项目生命周期](project-lifecycle.md) 找出依赖已满足、当前确有必要的任务:
1. 每项任务确定唯一主责角色、任务名称、协作角色、输入、产出和完成条件。
2. 同一角色、同一输入基线、紧密相关且共同交付的工作可以合并为一个任务包。
@@ -40,7 +47,7 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
正式执行任务使用简明中文说明:
- 要完成什么,为什么现在做;
- 当前阶段、必要背景、有效上游成果和版本;
- 当前阶段、本阶段完成约定、必要背景、有效上游成果和版本;
- 本次范围、不需要处理的内容、依赖、接口、限制和风险;
- 要提交的具体文件或成果包及最低内容;
- 证据、版本、下游用途、完成条件和期限;
@@ -56,8 +63,8 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
现阶段成员 Agent 之间不能直接通信。需要另一角色输入时:
1. 来源角色先问自己的负责人,把仍缺内容合并后交 Hub;
2. Hub 先查已登记事实,有答案就直接返回最少必要内容;
3. 没有答案时,Hub 向信息所有者角色创建定向查询或任务,不广播;
2. Hub 依次检查当前有效成果、《项目概览》中的已确认规则和项目已有资料,有答案就直接返回最少必要内容;
3. 没有答案时,Hub 向信息所有者角色创建定向查询或任务,不广播;只有当前确实需要且联络边界已批准时,才由相应真人负责人联系外部;
4. 目标角色完成专业自审和负责人确认后交 Hub;
5. Hub 校验并登记,再把确认结论和成果引用返回来源角色;
6. 跨角色讨论本身不是正式项目事实,只有经有权角色确认并登记的结论才能被下游依赖。
@@ -74,6 +81,8 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
- 多个结果在同一处理轮到达时,先统一更新依赖,再选择下一波次;
- 每条成员入站消息都形成回复、后续任务或明确结算,不能因跨角色转发而遗漏来源处理。
如果成果已经满足下文的接受条件并完成归档,随后出现 Etunel 任务记录“已处理”“未找到”等运行时异常,不撤销成果、不要求成员重复制作或重复提交,也不倒退已经完成的阶段;Hub 只记录并说明运行时异常。文件尚未实际收到、无法访问或尚未校验时,不能用消息状态代替成果继续推进。本 Skill 不规定队列、重试或去重实现。
消息关联所需的 `message_id``correlation_id` 只放在工具参数中,不要求负责人手工提供或复述。
## 校验、留档与下一步
@@ -87,6 +96,10 @@ Hub 检查:
- 模拟数据没有进入正式事实;
- 与其他有效成果没有未处理冲突,且未越权。
只有实际收到并能访问成果、来源角色正确、成果与当前任务及输入基线一致,并且必要版本、证据、自审和负责人确认满足要求时,Hub 才能选择“接受”。选择后必须在同一处理轮完成文件归档、成果索引更新并明确说明接受结论;这些动作全部完成后,成果才成为当前有效版本。附件刚送达、成员说“已完成”或消息显示“已处理”都不能单独视为接受。
同一文件、版本、适用范围和附带条件已经有有效负责人确认时,Hub 直接沿用,不再要求重复确认。只有内容或版本发生实质变化、当前用途超出原确认范围、负责人发生变化、确认证据缺失,或出现与原结论冲突的新事实时,才重新确认。
校验后用中文明确选择:
1. 接受:归档成果和版本,更新成果索引、项目进度、依赖和下一任务。
@@ -96,6 +109,8 @@ Hub 检查:
任务包只有全部必需产出满足才整体完成。项目文件、进度和阶段记录按 [项目文件与进度留档](project-files-and-progress.md) 更新。
当“本阶段必须完成”的事项和门禁全部满足,且成果、确认、索引、进度和阶段小结已经留档时,Hub 及时结束本阶段并启动下一阶段,不因可选完善或后续资料尚未提前收齐而继续追加当前阶段任务。下一阶段需要的资料由该阶段按需获取。
## 项目状态与同步
Hub 从项目创建起维护项目状态。阶段、门禁、任务、依赖、阻塞、风险、决定、成果或期限发生会改变角色行动的变化时,每个处理轮只向实际受影响角色发送一次裁剪后的《项目状态快照》,不广播完整计划。规则见 [角色与项目状态](project-status-and-membership.md)。
@@ -116,7 +131,7 @@ Hub 不把 AI 自主生成当成人类确认。Hub 自己的负责人在以下
## 阶段控制要点
- 阶段 1 业务草案后,产品按需选择早期风险预审角色;Hub 不增删名单。
- 阶段 2 可向适用专业角色形成评估波次,由产品收口。
- 阶段 2 由产品明确评审角色和具体问题;测试检查产品结果是否可判断,其他专业角色按实际影响参与,最后由产品收口。
- 阶段 3 先由技术负责人形成方案与接口草案,再向适用设计角色形成波次,最后由技术负责人收口统一版本。
- 阶段 4 Hub 基于已确认方案形成计划;技术负责人确认技术结构,只向有缺失或冲突的角色定向询问,业务授权真实资源和日期。
- 阶段 5 应用层、底层和硬件按依赖推进;底层先交应用层合版,统一固件只由应用层输出。
@@ -35,7 +35,7 @@ Hub 调用 `mcp__etunel__etunel_list_roles` 后,按目标 `role` 找到对应
## 提问、结果和阻塞
缺信息时一次说明:缺什么、为什么需要、缺失会影响什么、建议向谁获取、现在还能继续什么。不要逐句追问
缺信息时一次说明:缺什么、为什么现在需要、会影响本阶段哪项成果或决定、建议向谁获取、现在还能继续什么。只供后续阶段使用的信息明确写成后续依赖,不要逐句追问或把它说成当前阻塞
返回结果时先说结论,再列成果文件、关键验证、仍未解决的问题和下一步。专业细节放在成果文件中,不把大段日志粘到聊天正文。
@@ -22,7 +22,7 @@
- 要提交哪些文件或结果,做到什么程度算完成;
- 期限、风险、下游用途和需要负责人判断的事项。
一次列出当前能预见的信息缺口。负责人确认理解、补足输入或明确可以开始后再执行。不要复述运行时 ID、英文状态和内部字段;详细写法见 [人类可读沟通](human-readable-communication.md)。
同时理解 Hub 给出的本阶段完成约定,并在本角色范围内核对哪些是本阶段必须完成、哪些留到后续、哪些当前不需要。一次列出当前能预见的信息缺口。负责人确认理解、补足输入或明确可以开始后再执行。不要复述运行时 ID、英文状态和内部字段;详细写法见 [人类可读沟通](human-readable-communication.md)。
## 执行本角色任务
@@ -33,20 +33,23 @@
5. 未执行验证说明原因和影响,不虚构构建、测试、设备结果或人类意见。
6. 发现范围、接口、版本、角色责任、日期或验收变化时不静默修改基线,提交 Hub 走《变更申请》。
7. 产出完成前不频繁发进度;只有必要补充、正式阻塞或 Hub 明确要求的关键状态才发送非终态消息。
8. 发现后续阶段会需要的资料时向 Hub 登记,但不自动扩大当前任务,也不把它写成当前阻塞。
## 信息不足与跨角色输入
按以下顺序处理:
1. 检查任务和本会话已有的已确认输入
1. 依次检查任务附带的当前有效成果、项目已确认规则、本会话已有资料和其他可访问的项目文件
2. 一次向当前负责人列出缺什么、用途、影响和建议来源。
3. 负责人能提供则记录来源、范围、时间和条件后继续。
4. 负责人无法提供时,提醒其线下联系能解决问题的人;讨论结果回到本会话。
5. 仍无法解决,或必须取得另一角色正式成果时,向 Hub 发送一组问题,写明建议的信息所有者当前还能继续的范围。
5. 仍无法解决,或必须取得另一角色正式成果时,向 Hub 发送一组问题,写明建议的信息所有者、它影响哪项当前必需内容、为什么现在需要,以及当前还能继续的范围。
6. Hub 返回后核对来源角色、确认状态、成果版本、适用范围和风险,再继续任务。
不要逐句追问或绕过 Hub。普通问题默认一问一答;复杂异常按《会议决定记录》流程处理。
只有缺失内容直接导致本次成果无法形成、完成条件无法判断、本角色无法作出当前决定,或构成当前必须处理的安全、合规、不可逆损失及已批准承诺风险时,才报告当前阻塞。仅供后续阶段使用的资料作为后续依赖返回,不阻塞本任务。
## 专业自审与负责人确认
正式结果至少让 Hub 找到:
@@ -41,6 +41,12 @@ Hub 在项目开始时建立:
默认角色目录使用稳定的中文角色名。自定义角色使用 `etunel_list_roles` 返回的 `display_name`,去除目标文件系统不允许的字符;没有参与的角色不创建空目录。不得把文件散放到 `项目资料/` 根目录。
## 项目概览与已确认规则
`00-项目管理/项目概览.md` 保存项目名称、模式、流程基线、目标和当前项目已经确认的特殊规则。每条特殊规则写清内容、适用范围、决定权角色或负责人、确认依据和生效时间;Hub 只能登记有权负责人明确确认的内容,不能把自己的推断写成规则。
项目规则持续有效,直到同一决定权人明确修改或取消。各角色只确认自己决定权内的规则:产品确认产品行为和验收边界,业务确认业务范围、客户联络和正式承诺,Hub 负责人确认其权限内的流程处理,专业角色确认本领域限制;改变既有正式基线时仍按《变更申请》处理。
## 项目成员清单
项目创建完成、首个正式任务派发前,Hub 调用 `mcp__etunel__etunel_list_roles`。角色增加、替换或关系变化后重新查询。每次把同一份返回快照保存为:
@@ -57,14 +63,16 @@ Hub 在项目开始时建立:
- 有复盘价值的草案、退回材料、问题说明、会议决定、变更、豁免和阻塞资料放入 `过程记录/`
- 普通问答、空确认、重复副本和没有改变行动的临时文件不留档。
- 已接受版本不得覆盖。新版本保留旧版,并在 `成果索引.md` 写明当前有效版本、旧版及替代关系。
- 文档类正式成果只有在范围、行为、验收、接口、专业决定或下游行动发生实质变化时才创建新版本。状态、转交、排版、相同副本和不改变结论的过程说明不升正式版本;有复盘价值时写入阶段记录。代码、固件、硬件、配置和构建物仍按本领域规则区分实际版本。
- 固件、源码包、设计源文件等有既定文件名的技术成果保留原文件名,通过版本目录或成果索引区分版本。
- `成果索引.md` 至少写明中文成果名、阶段、主责角色、文件位置、版本、确认状态、当前适用性和被替代关系。
- 每项成果只标记一个当前有效版本。下游默认只接收该版本;旧版和过程材料继续留存,但不参与当前完成判断,除非被明确恢复为有效版本。
## 项目进度与阶段记录
`00-项目管理/项目进度.md` 是当前状态快照,使用简洁中文说明:当前阶段、已完成事项、正在进行事项、实际阻塞、下一步、责任角色和更新时间。
每个阶段 `阶段记录.md` 按时间追加有复盘价值的事件:任务波次开始、成果接收、影响进度的退回、阻塞与解除、重要决定、变更、门禁和阶段交接。每条记录写清发生了什么、相关文件、影响和下一步,不转存完整聊天或大段日志。
每个阶段开始时,在 `阶段记录.md` 写下简短的“本阶段完成约定”,分为本阶段必须完成、后续阶段处理和当前不需要;它不是新的正式成果。此后按时间追加有复盘价值的事件:任务波次开始、成果接收、影响进度的退回、阻塞与解除、重要决定、变更、门禁和阶段交接。每条记录写清发生了什么、相关文件、影响和下一步,不转存完整聊天或大段日志。
在以下事件后更新:
@@ -79,7 +87,9 @@ Etunel 的排队、送达、普通回复和空确认本身不触发留档。
## 阶段交接、跳转与回退
正式阶段交接前,Hub 确认适用的必要成果已保存、版本可识别、负责人确认已记录、成果索引已更新,并在来源阶段写入阶段小结。小结说明完成内容、有效文件、未决项、风险、门禁结论和下一阶段。
正式阶段交接前,Hub 确认本阶段必须完成的适用成果已保存、版本可识别、负责人确认已记录、成果索引已更新,并在来源阶段写入阶段小结。小结说明完成内容、有效文件、未决项、后续依赖、风险、门禁结论和下一阶段。
本阶段门禁通过并完成上述留档后,Hub 及时结束本阶段并启动下一阶段,不等待未来阶段的全部资料提前齐备。下一阶段根据实际任务按需索取自己的输入;可选完善和后续依赖不能继续占用已经完成的阶段。
跳转、回退、暂缓或返工时:
@@ -5,6 +5,9 @@ Hub 选择阶段、正式成果、任务波次、门禁或结项时读取。成
## 通用推进原则
- 正常顺序是:业务需求 → 产品定义 → 方案设计 → 项目规划 → 软硬件实现 → 测试验证 → 业务验收 → 发布结项。
- 每个阶段开始时,Hub 在该阶段的《阶段记录》中写下简短的“本阶段完成约定”,分为本阶段必须完成、后续阶段处理和当前不需要。Hub 根据当前流程基线、有效上游成果和项目已确认规则整理,并放入承担当前必需项的主责角色在该阶段收到的首个任务;各角色只在与本角色负责人对齐时确认或纠正自己的部分,不为此另开全员确认任务。
- 角色提出新缺口时,必须说明它直接影响哪项本阶段成果、完成条件或决定,以及为什么现在必须解决。后续使用但不影响当前判断的资料只登记为后续依赖,不能阻塞当前阶段。
- 本阶段门禁满足、成果和确认留档后,Hub 及时结束本阶段并启动下一阶段。不要等待后续阶段的全部资料提前齐备;新阶段根据自己的任务按需取得输入。
- 每项任务只有一个主责角色和一个可以独立判断的结果。
- 主责草案、专业角色评估或设计、主责收口的依赖不可颠倒;同一基线上的独立任务可以形成波次。
- 正式结果必须有指定成果、版本、证据、专业自审和负责人确认。
@@ -37,11 +40,13 @@ Hub 选择阶段、正式成果、任务波次、门禁或结项时读取。成
### 正常任务链
1. 产品基于阶段 1 基线形成产品定义草案、详细客户输入和可测试验收标准
2. Hub 以同一草案版本向适用的技术、实现、硬件和测试角色派发专业评估;依赖独立时可以形成波次
3. 各角色只返回本领域可行性、约束、缺口、风险和建议,不代产品改需求
4. 产品处置反馈、解决需求冲突并收口产品基线
5. 业务批准客户范围、验收边界和需要组织授权的结论
1. 产品优先使用阶段 1 基线、项目已确认规则和已有资料,明确产品做什么、不做什么、外部可见行为以及如何判断结果符合要求,形成三类正式成果草案;只补当前阶段确实需要的客户输入,不提前收齐后续设计、实现、测试或生产资料
2. 产品在评审请求中明确目标角色、具体问题、关联内容和期望输出。测试检查产品结果是否可判断;技术负责人、应用层、底层和硬件只在产品内容确实涉及本领域约束时参与。影响范围不清时,可先由技术负责人界定相关专业领域,不能因此默认让所有角色全面评估
3. Hub 以同一草案版本按产品指定范围定向派发;依赖独立时可以形成波次,但不得自行增加评审对象或专业问题
4. 各角色只返回本领域对当前产品定义的可行性、约束、缺口、风险和建议,并说明是否直接影响本阶段完成约定;不代产品改需求,也不把后续阶段的完整资料要求变成当前阻塞
5. 测试在本阶段判断预期结果是否清楚、可区分通过与不通过;样机数量、执行轮次、工具和详细环境通常留到《测试计划》或后续测试准备,除非它们本身是已批准的产品承诺
6. 产品处置反馈、解决需求冲突并收口产品基线。
7. 业务批准客户范围、验收边界和需要组织授权的结论。
### 正式成果
@@ -49,11 +54,11 @@ Hub 选择阶段、正式成果、任务波次、门禁或结项时读取。成
- 测试验收标准
- 里程碑要求
客户输入矩阵、平台 SDK、串号、对接资料、功能清单、器件和板框资料可以作为包内内容或支持输入不另立旧版开发资料包。
客户输入矩阵、平台 SDK、串号、对接资料、功能清单、器件和板框资料在当前产品定义确实需要时,可以作为包内内容或支持输入不另立旧版开发资料包,也不因后续阶段可能使用而要求阶段 2 全部收齐
### 门禁
产品行为、范围、异常边界、接口期望和客观验收标准明确;关键专业约束已处置;产品负责人确认,业务完成范围批准
产品行为、范围、异常边界、确有需要的接口期望和可判断的验收结果明确;直接影响当前定义的关键专业约束已处置;产品负责人确认,业务完成范围批准。已经登记的后续依赖不妨碍阶段交接,下一阶段启动后按需取得资料
## 阶段 3:方案设计
@@ -53,7 +53,8 @@ Hub 在项目开始和角色变化后调用 `mcp__etunel__etunel_list_roles`。
Hub 从项目创建起维护内部状态。阶段 1 至阶段 3 属于过程记录;阶段 4 将其正式化为《项目状态内部记录》,此后版本化维护。至少覆盖:
- 项目模式、流程基线、当前阶段和门禁;
- 项目模式、流程基线、当前阶段、本阶段完成约定和门禁;
- 当前项目已确认的特殊规则及其适用范围;
- 任务、主责角色、协作角色、依赖、期限和下一步;
- 当前角色列表、加入退出和交接状态;
- 风险、阻塞、决定、变更和豁免;
@@ -11,7 +11,7 @@
## 流程节点
- 阶段 1:在业务草案后判断是否需要早期专业风险预审,精确选择参与角色、问题和输入;Hub 不增删名单。
- 阶段 2:主责《立项资料》《测试验收标准》和《里程碑要求》,组织适用角色评估并收口产品基线。
- 阶段 2:主责《立项资料》《测试验收标准》和《里程碑要求》,明确做什么、不做什么、外部可见行为和可判断的验收结果,组织当前确有需要的角色评估并收口产品基线。
- 阶段 3:确认统一方案和接口没有需求漂移,不替技术角色编写专业设计。
- 阶段 4:向《产品资料整合》提供并确认产品资料索引、适用范围和版本。
- 阶段 7:主责业务验收组织,形成平台/客户验收报告和附条件事项,交业务批准。
@@ -24,7 +24,7 @@
- 对外可见交互、灯态、语音、产品侧产测要求和产品版本说明;
- 验收标准、需求追踪和需求歧义裁决;
- 客户输入清单和客户平台资料输入基线;
- 按已批准联络边界由真人负责人联系客户,获取平台 SDK、串号表、对接文档、接入标准和开发规范;
- 仅在当前任务确实需要且联络边界已经批准时,由真人负责人联系客户,获取平台 SDK、串号表、对接文档、接入标准和开发规范;
- 产品变更影响和实现/测试与产品基线的一致性;
- 平台及客户验收组织、差异分类和产品侧结论。
@@ -42,17 +42,17 @@
## 产品定义与专业评估
1. 接收业务目标、场景、范围内事项、范围外事项、成功指标、联络边界和材料引用。
2. 区分事实、假设、判断、决定、开放问题、依赖和风险
3. 形成产品定义草案、可测试验收标准和需求追踪。
4. 由 Hub 向技术负责人、应用层、底层硬件和测试中的适用角色派发同版本评估。
5. 产品处置专业反馈专业角色仍对自己的可行性和约束结论负责。
2. 优先使用当前有效成果、《项目概览》中的已确认规则和已有项目资料,不重复索取已经能够回答的问题
3. 区分事实、假设、判断、决定、开放问题、当前阻塞和后续依赖,形成产品定义草案、可判断的验收结果和需求追踪。
4. 提出专业评审请求时明确目标角色、具体问题、关联内容和期望输出。测试检查产品结果是否可判断;技术负责人、应用层、底层硬件只在内容确实涉及本领域约束时参与。影响范围不清时,请技术负责人界定相关专业领域,不能默认全角色全面评估。
5. 由 Hub 按产品指定范围派发同版本评估;产品处置专业反馈专业角色仍对自己的可行性和约束结论负责。仅供后续使用的资料不能自动成为当前阻塞。
6. 产品负责人确认基线;涉及客户范围、承诺或业务验收的内容取得业务批准。
器件、板框、SDK、串号和平台资料作为《立项资料》《产品资料整合》或专业成果的受控输入不另立旧版开发资料包,也不替代《硬件设计包》、实现资料或《测试计划》。
当前任务确实需要时,器件、板框、SDK、串号和平台资料可以作为《立项资料》《产品资料整合》或专业成果的受控输入不另立旧版开发资料包,也不替代《硬件设计包》、实现资料或《测试计划》。
## 客户平台资料
- 产品真人负责人按批准边界联系客户;Agent 不越过 Hub 直接给其他角色或未绑定客户派任务。
- 只有当前任务确实需要外部资料且联络边界已经批准时,产品真人负责人按批准边界联系客户;Agent 不越过 Hub 直接给其他角色或未绑定客户派任务。
- 资料记录来源、版本、适用产品/批次、获取时间、完整性、访问状态、开放问题和安全引用。
- 敏感凭据不进入普通成果或消息,只保存安全引用。
- 资料只有版本、适用范围、开放问题和必要确认清楚后才成为下游输入。
@@ -60,16 +60,18 @@
## 验收标准
验收标准至少包含前置条件、触发事件、预期结果、客观阈值、异常与恢复适用范围、环境和版本依赖。避免“体验良好”“功能正常”等不可判定表达。
阶段 2 的验收标准应把前置条件、触发事件、预期结果、必要阈值、异常与恢复适用范围写到足以判断产品结果,避免“体验良好”“功能正常”等不可判定表达。
样机数量、执行轮次、测试工具、详细环境和具体测试步骤通常由阶段 3《测试计划》或后续测试准备确定;只有它们本身属于已批准的产品承诺,或缺少它们就无法解释产品结果时,才必须在阶段 2 明确。
产品定义验收含义;测试设计和执行测试;业务批准客户验收口径。技术可行性或环境尚未确认时列为依赖,不伪装为可实施或已通过。
## 期望输入
- 业务确认的目标、范围、成功指标、客户事实和联络边界;
- 客户提供的平台资料及安全引用;
- 技术、应用层、底层、硬件可行性、约束、接口和工作量影响;
- 测试可测试性、环境、覆盖平台结果;
- 当前任务需要的客户平台资料及安全引用;
- 当前评审实际涉及的技术、应用层、底层、硬件可行性、约束、接口和工作量影响;
- 当前阶段需要的测试可测试性、环境、覆盖平台结果;
- Hub 的阶段、成果版本、依赖、状态快照和明确决定请求。
## 正式输出
@@ -78,7 +80,7 @@
- 测试验收标准;
- 里程碑要求;
- 产品需求、用户流程、功能/优先级/范围、设备行为和追踪关系;
- 客户输入清单与客户平台资料输入索引;
- 适用的客户输入清单与客户平台资料输入索引;
- 产品对方案无需求漂移的确认;
- 产品资料整合的产品侧输入;
- 平台提测通过报告、客户验收通过报告和附条件验收事项;
@@ -86,17 +88,17 @@
## 产品基线门槛
只有范围、流程、行为、异常和可测试验收明确,关键专业约束已取得对应角色反馈,影响核心定义的开放问题已关闭,其余依赖与风险已记录,并完成产品负责人和必要业务批准时,产品定义才可成为下游基线。
只有范围、流程、行为、异常和可判断的验收结果明确,直接影响当前产品定义的关键专业约束已取得对应角色反馈,影响核心定义的开放问题已关闭,其余后续依赖与风险已记录,并完成产品负责人和必要业务批准时,产品定义才可成为下游基线。
文档写完、研发已开工或计划日期到达都不能单独证明产品阶段完成。
## 何时请求 Hub 协调
- 业务目标、客户范围、成功指标或联络边界不清;
- 客户平台资料缺失、不可访问、版本冲突或适用范围不明;
- 当前产品定义确实依赖的客户平台资料缺失、不可访问、版本冲突或适用范围不明;
- 技术角色认为要求不可行或需要重大产品取舍;
- 多专业角色对行为、接口或责任理解不一致;
- 测试指出标准不可测、缺环境覆盖不足;
- 测试指出当前验收结果无法判断,或当前阶段确实需要的环境覆盖不足;
- 实现/测试观察与产品基线冲突;
- 变化需要业务批准、变更申请或多角色评估。
@@ -11,7 +11,7 @@
## 流程节点
- 阶段 1:仅在产品选择测试参与早期风险预审时评估验收、环境和周期风险。
- 阶段 2评估需求、验收标准、环境和专项是否可测试
- 阶段 2检查产品预期结果是否清楚,能否区分通过与不通过;不提前编写完整测试计划或索取后续执行资料
- 阶段 3:主责《测试计划》,并确认统一方案的可测试性和验证依赖。
- 阶段 6:主责五类正式测试成果,执行测试、管理缺陷并独立复验。
- 阶段 7:提供平台测试正式结果和验收证据,不替产品组织或业务批准。
@@ -20,6 +20,12 @@
测试可按依赖、设备和专项组织内部工作。独立判定的测试项可由 Hub 连续派发;紧密相关且共同出结论的测试可以组成一个任务包。
## 阶段 2 可测试性检查
阶段 2 只检查产品给出的前置条件、触发、预期结果、必要阈值、异常和适用范围是否足以判断产品符合要求。产品结果含糊、互相冲突或无法区分通过与不通过时,测试说明受影响内容和“为什么现在必须解决”,作为当前阶段问题返回。
样机数量、执行轮次、测试工具、详细环境、测试固件和具体步骤通常属于阶段 3《测试计划》或后续测试准备。除非它们本身是已批准的产品承诺,或缺少它们就无法解释产品结果,否则测试把它们登记为后续依赖,不用来阻塞阶段 2,也不为此加载后续专项细则。
## 独立质量决定权
版本整体质量结论只使用:
@@ -57,7 +63,7 @@
- 不用“不适用”表示时间不足、环境缺失、尚未执行、阻塞或失败;
- 不声称执行了未实际执行的测试、平台提交、试产或设备验证。
## 必要输入与提测边界
## 正式测试必要输入与提测边界
- 业务目标、成功指标和客户验收边界;
- 产品需求、异常行为和可测试验收标准;
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-09-02T16:01:57Z"
"syncedAt": "2026-09-03T02:30:34Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "5cca033c416427f26dbfc7baecb0ecfbb9757e41",
"commit": "69ec884e44d1050013724facfe452248e5b9de55",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-09-02T16:00:01Z"
"syncedAt": "2026-09-03T02:28:39Z"
}
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "1fd7ba6a72dfea7918106b4da7c665a58321454e",
"commit": "06abd6ed41a6d066f1be035852432f127687de0e",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-09-02T16:00:01Z"
"syncedAt": "2026-09-03T02:28:39Z"
}
@@ -48,6 +48,6 @@ An all-`web` deck never reads `image-generator.md`, and vice versa.
## 4. Credits and Handoff
License and attribution data live only in `project/images/image_sources.json` — never in `notes/*.md` (TTS would speak them), `total.md`, SVG `<title>` / `<desc>` (stripped on export), or a credits appendix slide. Executor renders inline credits per slide under [`executor-web-image.md`](./executor-web-image.md) and [`image-searcher.md`](./image-searcher.md) §7.
License and attribution data live only in `project/images/image_sources.json` — never in `notes/*.md` (TTS would speak them), `total.md`, or SVG `<title>` / `<desc>` (stripped on export). A closing sources page may summarize them, but it never replaces the per-slide credit: Executor renders inline credits per slide under [`executor-web-image.md`](./executor-web-image.md) and [`image-searcher.md`](./image-searcher.md) §7.
SVG authoring consumes `project/images/*.{jpg,png,webp}` and `image_sources.json`. Default Executor never invokes `image_gen.py` / `image_search.py` / `slice_images.py` / `image_treat.py` — missing material returns to Strategist-owned preparation; Quick finishes acquisition and derivation before authoring and neither acquires, derives, nor reselects while drawing. Completion: every row, file, manifest, and provenance record verified; Default proceeds to Executor, Quick exports only with validated evidence and usable statuses; report only blocking recovery.
@@ -66,7 +66,7 @@ The hash is a synchronization receipt, not proof of semantic equivalence; never
### Table schema — `ppt-master.semantic-table.v2`
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` / `runs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign`, `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` / `runs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
**Hard rule — the table payload is complete**: every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry).
@@ -187,7 +187,7 @@ These forms are needed only when the stated PPT behavior matters:
### 4.3 Element Grouping (Mandatory)
**Hard rule — root groups protect body-text layout**: every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group — one group is the image plus its overlay — so it takes no separate zone and creates no root-group overlap; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are the only exemptions; thresholds and estimator detail: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4.
**Hard rule — root groups protect body-text layout**: every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group, so it takes no separate zone and creates no root-group overlap, and a clipped picture's zone is its visible region; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are the only exemptions; thresholds and estimator detail: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4.
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Nested implementation groups may remain anonymous, need no bounds, and create no animation step; use them only when internal subunits (icon + title, value + label, repeated rows) are useful to edit — there is no default nesting pattern, depth, or quota. Titles, direct atomic Master/Layout elements, and canvas-level static framing — background images and full-canvas scrim/decoration rectangles — may remain root primitives; on flat pages give such framing a stable `id` plus `data-pptx-role="background"` / `"decoration"` and never add a `<g>` solely to silence an ungrouped-element advisory.
@@ -34,7 +34,7 @@ For specialized or regulated paper-figure subjects keep the prompt depth of [`im
## 3. Image Resource List
**Prepared derivatives**: keep the canonical row and add a deterministic child with a distinct `.png`, `Reference: Derived from <bare filename>; treatment=<operation>;`, inheriting acquisition (§4.4 follows `user` / `ai` above); lock placed children; [`image-base.md`](./image-base.md) §12 owns preparation.
**Prepared derivatives**: keep the canonical row and add a deterministic child with a distinct `.png`, `Reference: Derived from <bare filename>; treatment=<operation>;`, inheriting acquisition (§4.4 follows `user` / `ai` above); lock placed children; when the canonical original itself never appears on a page, give its row `Type: Source` — like an Illustration Sheet it stays out of the lock and off every page, and the checker requires at least one row derived from it; [`image-base.md`](./image-base.md) §12 owns preparation.
**References describe visual intent**: AI rows carry subject + intent + composition without repeating rendering or HEX; web rows carry the exact subject, view/mood, focal/quiet region, crop safety, and positive quality cues, from which Image_Searcher derives a separate short provider query (complete entity names or disambiguation may use more words). When page use depends on stable composition, put subject/quiet zones, boundary or direction, intended overlap/seam, and approximate share in `Reference` or the §IX block, not only in `Image pattern`.
@@ -180,7 +180,8 @@ duotone, Gaussian blur, and `--fit WxH` (downscale to fit inside a pixel
box, aspect ratio and alpha preserved; never upscales). They compose in a
fixed order: brightness → contrast → tone treatment → blur → fit. Desaturation, grayscale, and duotone are
mutually exclusive. At least one option must produce a real change; animated
or multi-frame sources are rejected rather than reduced to one frame.
sources are rejected rather than reduced to one frame, while a camera
multi-picture JPEG (MPO) contributes its primary frame.
Both input and output are bare filenames directly under `images/`; output must
be a new `.png` file. The tool keeps the EXIF-corrected display dimensions,
@@ -279,11 +280,11 @@ python3 scripts/image_search.py "abstract gradient" \
Suitability & manual replacement (a web top hit is metadata-relevant, not guaranteed visually right):
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution).
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution). A downloaded camera multi-picture JPEG (MPO, common among Commons originals) is rewritten as its primary frame before validation so every later consumer sees a single-frame JPEG.
- For exact subjects (landmarks, people, companies, products), use `--require-terms` or batch `required_terms` so visually plausible but wrong metadata is rejected before ranking. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"`. Keep proper-name / geography anchors; do not broaden to generic terms like `canyon`, `stone pillar`, or `ancient town` just to improve coverage.
- When the current Generate agent can inspect images, use `--save-candidates`. The tool saves only the first ranked page of review-eligible provider previews (**8 by default**), writes `candidates/<stem>/review_sheet.jpg`, and leaves the target image and `image_sources.json` untouched. Standalone CLI use remains best-only unless this flag is explicit.
- Compare the thumbnail set against the active Reference/Crop Policy. Only after one passes, run `--promote candidate_03.jpg --filename <name>.jpg`; this downloads and validates exactly that original. In batch mode, pass the same `--batch images/image_queries.json` so `Needs-Selection` becomes `Sourced`.
- If no thumbnail passes and `has_more_candidates` is true, fetch `--candidate-page 2` (or set the batch row's `candidate_page` to `next_candidate_page` and reset it to `Pending`). Candidate numbering continues at 9; no original is downloaded. Only after the pool is exhausted should you materially change the identity wording, viewpoint, translation, alias, or disambiguator and generate a fresh pool.
- If no thumbnail passes and `has_more_candidates` is true, fetch `--candidate-page 2` with the same query and filename — the pool's saved `required_terms`, `query_variants`, orientation, and minimum dimensions are inherited unless given again, and a changed query is refused as a page continuation (start a new page-1 pool instead). In batch mode pass `--candidate-page 2` to rerun every `Needs-Selection` row at that page, or set one row's `candidate_page` to `next_candidate_page` and reset only it to `Pending`. Candidate numbering continues at 9, earlier pages stay in `candidates.json` (`saved_pages`) so any saved thumbnail remains promotable, and no original is downloaded. Only after the pool is exhausted should you materially change the identity wording, viewpoint, translation, alias, or disambiguator and generate a fresh pool.
- Without multimodal inspection, omit `--save-candidates`. Best-only mode rejects visual-verification-required near matches, accepts only a strict metadata candidate, downloads one original, and records `selection_method: metadata-ranked`; if metadata cannot prove the entity or the active visual requirement, use `Needs-Manual` rather than claiming visual confirmation.
- `--from-url <url> --filename <name>.jpg` downloads a user-chosen image URL and replaces the target (recorded `license_tier: manual`) — the model-agnostic manual path; works even without a multimodal model.
@@ -291,7 +292,7 @@ Full review / escalation flow: [`image-searcher.md`](../../references/image-sear
Output:
- `--save-candidates`: thumbnail-only `candidates/<stem>/candidates.json`, at most 8 provider previews by default, and `review_sheet.jpg`; no target image or provenance entry. `--candidate-page N` advances through the ranked pool; `--max-candidates 0` explicitly dumps all candidates for exceptional debugging
- `--save-candidates`: thumbnail-only `candidates/<stem>/candidates.json`, at most 8 provider previews by default, and `review_sheet.jpg`; no target image or provenance entry. `--candidate-page N` advances through the ranked pool while keeping earlier pages' entries in `candidates.json`; `--max-candidates 0` explicitly dumps all candidates for exceptional debugging
- Best-only / `--promote`: one original saved to the specified output directory (auto-converts webp → jpg via Pillow when the filename extension demands)
- Best-only / `--promote`: `image_sources.json` manifest with full provenance (provider, license, license_tier, author, source URL, dimensions, attribution_text)
- Manifest is idempotent on `filename` and written atomically; damaged existing provenance blocks replacement
@@ -315,14 +316,14 @@ The full role-level reference (intent → query translation, on-slide attributio
| `--require-terms` | — | Repeatable identity gate; comma separates groups, `A|B` aliases |
| `--save-candidates` | off | Thumbnail mode: one ranked page of previews plus `review_sheet.jpg`, no original |
| `--max-candidates` | `8` | Page size; `0` = complete pool, debugging only |
| `--candidate-page` | `1` | Ranked page; page 2 starts at rank 9 |
| `--candidate-page` | `1` | Ranked page; page 2 starts at rank 9. Single-query continuation inherits the saved pool request; batch reruns every `Needs-Selection` row at that page |
| `--promote <candidate>` | — | Download exactly one selected original, enforce gates, write provenance |
| `--from-url <url>` | — | Manual replacement recorded as `license_tier: manual`; works without vision |
| `--manifest <path>` | `images/image_queries.json` | Override the manifest path |
### Batch runner and ranking
Required per item: `filename`, `query`, `status`; optional: `query_variants`, `candidate_page`, `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`. The runner revalidates every `Sourced` row against its file, dimensions, and manifest entry (drift → `Failed`), then searches all `Pending` / `Failed` rows concurrently (default concurrency 3, `--concurrency N` or `IMAGE_SEARCH_CONCURRENCY`; `1` for strict pacing on rate-sensitive free providers). Thumbnail mode writes `Needs-Selection` with `candidate_page`, `candidate_count`, `candidate_total`, `has_more_candidates`, `next_candidate_page`, and the `review_sheet` path, creating no image or provenance; to see the next page for one row set its `candidate_page` to `next_candidate_page`, reset only that row to `Pending`, and rerun. Promoting with the same `--batch` manifest moves the row to `Sourced`. Provider failures stay retryable `Failed`; clean exhaustion becomes `Needs-Manual`; status is saved after each completion.
Required per item: `filename`, `query`, `status`; optional: `query_variants`, `candidate_page`, `slide`, `purpose`, `orientation`, `provider`, `strict_no_attribution`, `min_width`, `min_height`, `required_terms`. The runner revalidates every `Sourced` row against its file, dimensions, and manifest entry (drift → `Failed`), then searches all `Pending` / `Failed` rows concurrently (default concurrency 3, `--concurrency N` or `IMAGE_SEARCH_CONCURRENCY`; `1` for strict pacing on rate-sensitive free providers). Thumbnail mode writes `Needs-Selection` with `candidate_page`, `candidate_count`, `candidate_total`, `has_more_candidates`, `next_candidate_page`, and the `review_sheet` path, creating no image or provenance; to see the next page for one row set its `candidate_page` to `next_candidate_page`, reset only that row to `Pending`, and rerun; `--candidate-page N` on the batch run advances every `Needs-Selection` row at once. A positional query is rejected in batch mode rather than ignored. Promoting with the same `--batch` manifest moves the row to `Sourced`. Provider failures stay retryable `Failed`; clean exhaustion becomes `Needs-Manual`; status is saved after each completion.
**Ranking** orders provider metadata, never pixels, and must not be tuned into a taste engine: hard-reject invalid licenses and zero relevance; in best-only mode reject any candidate missing a `required_terms` group; in thumbnail mode keep strict matches first and admit a near match only when exactly one group is missing and the finding query still has strong relevance (marked `identity_evidence: visual-verification-required`, never auto-promoted); then metadata-verified identity in the title outranks a URL-only match; concrete query tokens match whole ASCII tokens (`office``officer`) and dominate generic words; orientation is a small penalty, no-attribution a small bonus, pixel count capped so a huge weak match cannot beat a smaller accurate one.
@@ -507,7 +507,9 @@ implementation before publishing their authoring SVG. Standard workflows do
not rewrite completed SVG: they pass `--canonical-authoring` to
`svg_quality_checker.py`, which reports any remaining deterministic change as an
advisory warning (run `compact_svg_styles.py <svg_output> --inplace` on
authored project pages and rerun the final gate to normalize, or keep the
authored project pages, re-run `stamp_native_fallbacks.py --write` on pages
that carry Chart/Table fallbacks because the rewrite changes their
fingerprinted subtree, then rerun the final gate to normalize, or keep the
explicit form). Structured template rosters keep their explicit form: per-slide
compaction would make shared Master/Layout atoms diverge and shift native
fallback hashes, so the normalizer is not applied to them; mirror
@@ -434,6 +434,59 @@ def _validate_downloaded_quality(
return False
def _normalize_multi_frame_jpeg(path: Path) -> bool:
"""Rewrite a camera MPO (multi-picture JPEG) as its primary frame in place.
Photo hosts serve stereo/multi-frame camera originals under a ``.jpg``
extension. ``image_treat.py``, the quality checker, and the PPTX exporter
all expect a single-frame JPEG, so the primary frame is re-encoded at high
quality with its EXIF and ICC data and the sibling frames are dropped.
Returns ``True`` when the file was rewritten.
"""
if path.suffix.lower() not in {".jpg", ".jpeg"}:
return False
try:
from PIL import Image # type: ignore
except ImportError:
return False
try:
with Image.open(path) as source:
multi_frame = (
(source.format or "").upper() == "MPO"
or int(getattr(source, "n_frames", 1)) != 1
)
if not multi_frame:
return False
source.seek(0)
frame = source.convert("RGB")
save_kwargs: dict[str, object] = {"quality": 95}
for key in ("exif", "icc_profile"):
value = source.info.get(key)
if value:
save_kwargs[key] = value
except (OSError, ValueError, SyntaxError):
return False
fd, temp_name = tempfile.mkstemp(
prefix=f".{path.stem}.frame0.",
suffix=path.suffix,
dir=str(path.parent),
)
os.close(fd)
temp_path = Path(temp_name)
try:
frame.save(temp_path, "JPEG", **save_kwargs)
os.replace(temp_path, path)
except (OSError, ValueError):
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
return False
finally:
frame.close()
return True
def _stage_and_validate_image(
output_path: Path,
materialize: Callable[[Path], object],
@@ -455,6 +508,11 @@ def _stage_and_validate_image(
keep_temp = False
try:
materialize(temp_path)
if _normalize_multi_frame_jpeg(temp_path):
print(
" normalized a multi-frame camera JPEG (MPO) to its primary frame",
file=sys.stderr,
)
if not _validate_downloaded_quality(
temp_path,
min_width=min_width,
@@ -710,6 +768,56 @@ def _dedupe_ranked_candidates(
return deduped
def _retained_pool_candidates(
meta_path: Path,
review_dir: Path,
request: ImageSearchRequest,
*,
current_ranks: set[int],
) -> tuple[list[dict], list[int]]:
"""Keep earlier pages of the same pool so any saved thumbnail stays promotable.
A pool belongs together while the target filename and query are unchanged;
a new query replaces it. Entries whose review file disappeared are dropped.
"""
if not meta_path.is_file():
return [], []
try:
existing = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return [], []
if not isinstance(existing, dict):
return [], []
prior_request = existing.get("request")
if (
existing.get("target_filename") != request.filename
or not isinstance(prior_request, dict)
or str(prior_request.get("query") or "") != request.query
):
return [], []
retained: list[dict] = []
for entry in existing.get("candidates") or []:
if not isinstance(entry, dict):
continue
rank = entry.get("rank")
review = entry.get("review")
if (
not isinstance(rank, int)
or isinstance(rank, bool)
or rank in current_ranks
or not isinstance(review, str)
or not (review_dir / Path(review).name).is_file()
):
continue
retained.append(entry)
saved_pages = [
page
for page in existing.get("saved_pages") or [existing.get("candidate_page")]
if isinstance(page, int) and not isinstance(page, bool) and page > 0
]
return retained, saved_pages
def _save_candidate_thumbnails(
ranked: list[tuple[float, str, AssetCandidate]],
output_dir: Path,
@@ -830,6 +938,14 @@ def _save_candidate_thumbnails(
except OSError:
pass
meta_path = cand_dir / "candidates.json"
retained, saved_pages = _retained_pool_candidates(
meta_path,
review_dir,
request,
current_ranks={entry["rank"] for entry in pool},
)
saved_pages = sorted(set(saved_pages) | {candidate_page})
meta = {
"schema_version": 3,
"candidate_storage": "thumbnail-only",
@@ -838,6 +954,7 @@ def _save_candidate_thumbnails(
"searched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"license_stage": license_stage,
"candidate_page": candidate_page,
"saved_pages": saved_pages,
"page_size": max_candidates,
"candidate_total": candidate_total,
"has_more_candidates": has_more_candidates,
@@ -851,9 +968,8 @@ def _save_candidate_thumbnails(
"min_width": request.min_width,
"min_height": request.min_height,
},
"candidates": pool,
"candidates": sorted(retained + pool, key=lambda entry: entry["rank"]),
}
meta_path = cand_dir / "candidates.json"
_write_json_atomic(meta_path, meta)
review_sheet: Optional[Path] = None
@@ -1314,6 +1430,27 @@ def write_sources_manifest(path: Path, item: dict) -> Path:
# ---------------------------------------------------------------------------
def _load_saved_candidate_request(
output_dir: Path,
target_filename: str,
) -> Optional[dict]:
"""Return the ``request`` block of an existing candidate pool, if any."""
stem = Path(target_filename).stem
meta_path = output_dir / "candidates" / stem / "candidates.json"
if not meta_path.is_file():
return None
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(meta, dict):
return None
if meta.get("target_filename") != target_filename:
return None
request = meta.get("request")
return request if isinstance(request, dict) else None
def promote_candidate(
output_dir: Path,
target_filename: str,
@@ -2023,6 +2160,25 @@ def run_search_manifest(
if repaired_sourced:
save_search_manifest(manifest_path, manifest)
if candidate_page > 1:
# An explicit page on the command line continues every shortlisted
# row; Needs-Selection is otherwise terminal, so without this the
# flag would silently do nothing.
advanced = 0
for item in items:
if item["status"] != SEARCH_STATUS_NEEDS_SELECTION:
continue
item["candidate_page"] = candidate_page
item["status"] = SEARCH_STATUS_PENDING
advanced += 1
if advanced:
print(
f"[Batch] --candidate-page {candidate_page}: {advanced} "
"Needs-Selection row(s) rerun at that page; set one row's "
"candidate_page in the manifest instead to advance only it."
)
save_search_manifest(manifest_path, manifest)
pending_idx = [
i for i, it in enumerate(items)
if it["status"] in SEARCH_RETRYABLE_STATUSES
@@ -2415,6 +2571,11 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.error("--candidate-page must be a positive integer")
if args.max_candidates == 0 and args.candidate_page != 1:
parser.error("--candidate-page must be 1 when --max-candidates is 0")
if args.batch and args.query and not args.promote and not args.from_url:
parser.error(
"a positional query is not used in --batch mode; put the query in "
"the manifest row, or drop --batch for a single-query search"
)
output_dir = Path(args.output)
@@ -2501,16 +2662,68 @@ def main(argv: Optional[list[str]] = None) -> int:
if not args.filename:
parser.error("--filename is required in single-query mode")
saved_request = _load_saved_candidate_request(output_dir, args.filename)
if args.candidate_page > 1 and saved_request is None:
parser.error(
f"--candidate-page {args.candidate_page} continues an existing "
f"candidate pool, but none was saved for {args.filename!r}; run "
"page 1 with --save-candidates first"
)
if saved_request is not None and args.candidate_page > 1:
saved_query = str(saved_request.get("query") or "")
if saved_query and saved_query != args.query:
parser.error(
f"--candidate-page {args.candidate_page} must keep the pool's "
f"query {saved_query!r}; a changed query starts a new page-1 "
"pool instead"
)
required_terms = _parse_required_terms(args.require_terms)
query_variants = _parse_query_variants(args.query_variant)
orientation = "" if args.orientation == "any" else args.orientation
min_width = args.min_width
min_height = args.min_height
if saved_request is not None and args.candidate_page > 1:
inherited: list[str] = []
if not required_terms and saved_request.get("required_terms"):
required_terms = _parse_required_terms(saved_request["required_terms"])
inherited.append(f"required_terms={list(required_terms)}")
if not query_variants and saved_request.get("query_variants"):
query_variants = _parse_query_variants(saved_request["query_variants"])
inherited.append(f"query_variants={list(query_variants)}")
saved_orientation = str(saved_request.get("orientation") or "any")
if not orientation and saved_orientation != "any":
orientation = saved_orientation
inherited.append(f"orientation={orientation}")
for field in ("min_width", "min_height"):
saved_value = saved_request.get(field)
if (
isinstance(saved_value, int)
and not isinstance(saved_value, bool)
and saved_value > 0
and getattr(args, field) == parser.get_default(field)
and saved_value != getattr(args, field)
):
if field == "min_width":
min_width = saved_value
else:
min_height = saved_value
inherited.append(f"{field}={saved_value}")
if inherited:
print(
" continuing the saved candidate pool; inherited "
+ ", ".join(inherited),
file=sys.stderr,
)
request = ImageSearchRequest(
query=args.query,
purpose=args.purpose,
orientation="" if args.orientation == "any" else args.orientation,
orientation=orientation,
filename=args.filename,
slide=args.slide,
min_width=args.min_width,
min_height=args.min_height,
required_terms=_parse_required_terms(args.require_terms),
query_variants=_parse_query_variants(args.query_variant),
min_width=min_width,
min_height=min_height,
required_terms=required_terms,
query_variants=query_variants,
)
_warn_weak_required_terms(request.required_terms)
@@ -332,9 +332,14 @@ def _apply_treatments(
try:
with Image.open(source_path) as source:
if int(getattr(source, "n_frames", 1)) != 1:
raise RuntimeError(
f"animated or multi-frame images are unsupported: {source_path}"
)
if (source.format or "").upper() == "MPO":
# A camera multi-picture JPEG: the primary frame is the
# photograph; the sibling frames are stereo/preview data.
source.seek(0)
else:
raise RuntimeError(
f"animated images are unsupported: {source_path}"
)
oriented = ImageOps.exif_transpose(source)
try:
oriented.load()
@@ -1133,6 +1133,7 @@ class SVGQualityChecker:
canonical_authoring: bool = False,
):
self.template_mode = template_mode
self._image_pixel_sizes: Dict[Path, Tuple[int, int]] = {}
self.scan_banner = True
self.quick_generate = quick_generate
self.canonical_authoring = canonical_authoring
@@ -2191,8 +2192,9 @@ class SVGQualityChecker:
result['warnings'].extend(
f"Noncanonical compact authoring: {error} "
"(advisory; normalize with "
"`python3 scripts/compact_svg_styles.py <svg_output> --inplace` "
"and rerun the final gate, or leave the explicit form)"
"`python3 scripts/compact_svg_styles.py <svg_output> --inplace`, "
"re-stamp pages that carry Chart/Table fallbacks, and rerun the "
"final gate, or leave the explicit form)"
for error in errors
)
@@ -7433,6 +7435,47 @@ class SVGQualityChecker:
f"images/{filename} does not exist.",
))
# ``Type: Source`` marks a web/user/ai original that only feeds
# prepared derivatives; like a sheet it never enters the lock or a page.
derived_parents = set()
for row in rows:
match = re.search(
r'derived\s+from\s+`?([^;|`]+?)`?\s*(?:;|$)',
row.get('Reference', ''),
re.IGNORECASE,
)
if match:
derived_parents.add(Path(match.group(1).strip()).name)
for row in rows:
if self._row_type(row).lower() != 'source':
continue
filename = self._row_filename(row)
if not filename:
continue
if filename in lock_images:
self._illustration_issues.append((
'error',
'source_in_lock',
f"{filename} is a Source row (unplaced derivation parent) "
"but is listed in spec_lock.md images; lock only its "
"placed derivatives.",
))
if filename in all_svg_references:
self._illustration_issues.append((
'error',
'source_referenced',
f"{filename} is a Source row but is referenced by an SVG; "
"give the row a placed Type or place a derivative instead.",
))
if filename not in derived_parents:
self._illustration_issues.append((
'error',
'source_without_derivative',
f"{filename} is a Source row but no row is `Derived from "
f"{filename}`; a Source row exists only to feed prepared "
"derivatives.",
))
if current_contract:
self._check_planned_image_closure(
rows,
@@ -7648,7 +7691,12 @@ class SVGQualityChecker:
inline_counts: Dict[Path, int] = {}
placements: Dict[
str,
List[Tuple[Path, str, Tuple[str, ...]]],
List[Tuple[
Path,
str,
Tuple[str, ...],
Tuple[float, float, float, float] | None,
]],
] = defaultdict(list)
for svg_path in discover_slide_svgs(svg_dir):
try:
@@ -7679,6 +7727,11 @@ class SVGQualityChecker:
working_root,
parent_by_id,
),
cls._image_frame_geometry(
element,
working_root,
parent_by_id,
),
))
if _resolve_external_image_reference is not None:
resolved = _resolve_external_image_reference(
@@ -7692,6 +7745,88 @@ class SVGQualityChecker:
inline_counts[svg_path] = inline_count
return out, inline_counts, dict(placements)
@staticmethod
def _image_frame_geometry(
image: ET.Element,
root: ET.Element,
parent_by_id: Dict[int, ET.Element],
) -> Tuple[float, float, float, float] | None:
"""Return (frame width, frame height, source fraction w, h) of one instance.
Inside the nested-``<svg>`` crop transport the frame is the wrapper and
the ``viewBox`` selects a unit-coordinate fraction of the source; a
plain ``<image>`` shows the whole source in its own box.
"""
def _number(raw: str | None) -> float | None:
if raw is None:
return None
try:
value = float(raw.strip().removesuffix('px'))
except (TypeError, ValueError):
return None
return value if math.isfinite(value) and value > 0 else None
parent = parent_by_id.get(id(image))
if (
parent is not None
and parent is not root
and _local_name(parent) == 'svg'
and parent.get('viewBox')
):
parts = [
part for part in re.split(r'[\s,]+', parent.get('viewBox', '').strip())
if part
]
if len(parts) != 4:
return None
width = _number(parent.get('width'))
height = _number(parent.get('height'))
fraction_width = _number(parts[2])
fraction_height = _number(parts[3])
if None in (width, height, fraction_width, fraction_height):
return None
return (width, height, fraction_width, fraction_height)
width = _number(image.get('width'))
height = _number(image.get('height'))
if width is None or height is None:
return None
return (width, height, 1.0, 1.0)
def _measure_image_pixels(
self,
paths: set[Path] | None,
) -> Tuple[int, int] | None:
"""Return the EXIF-oriented pixel size of the first readable file."""
for path in sorted(paths or ()):
cached = self._image_pixel_sizes.get(path)
if cached is not None:
return cached
try:
from PIL import Image, ImageOps # type: ignore
with Image.open(path) as image:
oriented = ImageOps.exif_transpose(image)
size = (int(oriented.width), int(oriented.height))
except Exception: # noqa: BLE001 - unreadable files are reported elsewhere
continue
if size[0] > 0 and size[1] > 0:
self._image_pixel_sizes[path] = size
return size
return None
@staticmethod
def _stretch_deviation(
geometry: Tuple[float, float, float, float] | None,
source_size: Tuple[int, int] | None,
) -> float | None:
"""Return how far a ``none`` placement departs from the source aspect."""
if geometry is None or source_size is None:
return None
frame_width, frame_height, fraction_width, fraction_height = geometry
expected = (fraction_width * source_size[0]) / (fraction_height * source_size[1])
if expected <= 0:
return None
return abs((frame_width / frame_height) / expected - 1.0)
@staticmethod
def _image_crop_mechanisms(
image: ET.Element,
@@ -7885,7 +8020,7 @@ class SVGQualityChecker:
placed_rows = [
row for row in rows
if self._row_type(row).lower() != 'illustration sheet'
if self._row_type(row).lower() not in {'illustration sheet', 'source'}
and self._row_acquire(row)
in {'ai', 'web', 'user', 'formula', 'placeholder', 'slice'}
]
@@ -8104,20 +8239,27 @@ class SVGQualityChecker:
if effective_no_crop:
placements_by_svg: Dict[
Path,
List[Tuple[str, Tuple[str, ...]]],
List[Tuple[
str,
Tuple[str, ...],
Tuple[float, float, float, float] | None,
]],
] = defaultdict(list)
for svg_path, raw_aspect, mechanisms in image_placements.get(
filename,
[],
for svg_path, raw_aspect, mechanisms, geometry in (
image_placements.get(filename, [])
):
placements_by_svg[svg_path].append((
raw_aspect,
mechanisms,
geometry,
))
source_size = self._measure_image_pixels(
referenced_paths.get(filename),
)
for svg_path, placements in placements_by_svg.items():
parsed_placements = []
for raw_aspect, mechanisms in placements:
for raw_aspect, mechanisms, geometry in placements:
try:
align, mode = (
_parse_project_image_aspect_ratio(raw_aspect or None)
@@ -8132,33 +8274,55 @@ class SVGQualityChecker:
mechanisms,
align,
mode,
geometry,
))
has_complete_placement = any(
align != 'none'
and mode == 'meet'
and not mechanisms
for _raw_aspect, mechanisms, align, mode
for _raw_aspect, mechanisms, align, mode, _geometry
in parsed_placements
)
for raw_aspect, _mechanisms, align, _mode in parsed_placements:
for raw_aspect, _mechanisms, align, _mode, geometry in (
parsed_placements
):
if align != 'none':
continue
# ``none`` is the mandated form inside the nested crop
# transport and is harmless on a frame that keeps the
# source aspect; only measured distortion is a stretch.
deviation = self._stretch_deviation(geometry, source_size)
if deviation is not None and deviation <= 0.02:
continue
actual = raw_aspect or '(implicit xMidYMid meet)'
if deviation is None:
detail = (
"its frame cannot be checked against the source "
"pixel aspect"
)
else:
detail = (
f"its frame distorts the source aspect by "
f"{deviation * 100:.1f}%"
)
self._illustration_issues.append((
'error',
'no_crop_image_fit_mismatch',
f"{svg_path.name}: {filename} is no-crop but its "
f"rendered placement uses "
f"preserveAspectRatio={actual!r}; stretching is not "
"a detail crop and remains forbidden.",
f"preserveAspectRatio={actual!r} and {detail}; "
"stretching is not a detail crop and remains "
"forbidden.",
))
if has_complete_placement:
continue
for raw_aspect, mechanisms, align, mode in parsed_placements:
for raw_aspect, mechanisms, align, mode, _geometry in (
parsed_placements
):
if align != 'none' and mode != 'meet':
actual = raw_aspect or '(implicit xMidYMid meet)'
self._illustration_issues.append((
@@ -306,6 +306,10 @@ def _valid_project_image_payload(img_format: str, img_data: bytes) -> bool:
image.verify()
except (UnidentifiedImageError, OSError, ValueError, SyntaxError):
return False
if expected == 'JPEG' and actual == 'MPO':
# A camera multi-picture JPEG is a JPEG stream followed by sibling
# frames; every JPEG decoder renders its primary frame.
return True
return actual == expected
@@ -91,3 +91,94 @@ class ImageSearchPromotionTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class CandidatePoolContinuationTests(unittest.TestCase):
def test_later_pages_keep_earlier_saved_thumbnails(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
cand_dir = Path(temp_dir) / "candidates" / "hero"
review_dir = cand_dir / "review"
review_dir.mkdir(parents=True)
(review_dir / "candidate_02.jpg").write_bytes(b"x")
meta_path = cand_dir / "candidates.json"
meta_path.write_text(json.dumps({
"target_filename": "hero.jpg",
"candidate_page": 1,
"request": {"query": "bronze mask"},
"candidates": [
{"rank": 1, "filename": "candidate_01.jpg", "review": "review/candidate_01.jpg"},
{"rank": 2, "filename": "candidate_02.jpg", "review": "review/candidate_02.jpg"},
],
}), encoding="utf-8")
request = image_search.ImageSearchRequest(
query="bronze mask",
purpose="",
orientation="",
filename="hero.jpg",
slide="",
min_width=1,
min_height=1,
)
retained, pages = image_search._retained_pool_candidates(
meta_path,
review_dir,
request,
current_ranks={9, 10},
)
self.assertEqual([entry["rank"] for entry in retained], [2])
self.assertEqual(pages, [1])
changed = image_search.ImageSearchRequest(
query="jade cong",
purpose="",
orientation="",
filename="hero.jpg",
slide="",
min_width=1,
min_height=1,
)
self.assertEqual(
image_search._retained_pool_candidates(
meta_path, review_dir, changed, current_ranks=set()
),
([], []),
)
def test_batch_mode_rejects_a_positional_query(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
manifest = Path(temp_dir) / "image_queries.json"
manifest.write_text('{"items": []}', encoding="utf-8")
with redirect_stderr(io.StringIO()) as stderr:
with self.assertRaises(SystemExit):
image_search.main(["--batch", str(manifest), "bronze mask"])
self.assertIn("positional query", stderr.getvalue())
def test_page_continuation_requires_a_saved_pool(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with redirect_stderr(io.StringIO()) as stderr:
with self.assertRaises(SystemExit):
image_search.main([
"bronze mask",
"--filename", "hero.jpg",
"-o", temp_dir,
"--save-candidates",
"--candidate-page", "2",
])
self.assertIn("none was saved", stderr.getvalue())
def test_multi_frame_camera_jpeg_keeps_its_primary_frame(self) -> None:
try:
from PIL import Image
except ImportError: # pragma: no cover - Pillow is a runtime dependency
self.skipTest("Pillow unavailable")
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "camera.jpg"
first = Image.new("RGB", (64, 48), (200, 20, 20))
second = Image.new("RGB", (64, 48), (20, 20, 200))
first.save(path, format="MPO", save_all=True, append_images=[second])
self.assertTrue(image_search._normalize_multi_frame_jpeg(path))
with Image.open(path) as image:
self.assertEqual(image.format, "JPEG")
self.assertEqual(getattr(image, "n_frames", 1), 1)
self.assertGreater(image.getpixel((3, 3))[0], 150)
self.assertFalse(image_search._normalize_multi_frame_jpeg(path))
@@ -51,7 +51,7 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
self.assertEqual(result.returncode, 1)
self.assertIn("key background #00FF00", result.stderr)
self.assertIn("dominant #57B265", result.stderr)
self.assertIn("max channel spread 3", result.stderr)
self.assertIn("key spread 3", result.stderr)
self.assertIn("--bg #57B265 --tolerance 12", result.stderr)
self.assertFalse((output_dir / "element.png").exists())
@@ -288,3 +288,35 @@ class SVGQualityCheckerBoundsTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class NoCropStretchMeasurementTests(unittest.TestCase):
def test_nested_crop_frame_geometry_and_measured_deviation(self) -> None:
root = ET.fromstring(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
'<svg x="140" y="60" width="180" height="180" '
'viewBox="0.6 0.3 0.24 0.16" preserveAspectRatio="none" '
'overflow="hidden">'
'<image href="../images/tree.jpg" x="0" y="0" width="1" height="1" '
'preserveAspectRatio="none"/></svg>'
'<image href="../images/tree.jpg" x="0" y="0" width="405" '
'height="720" preserveAspectRatio="none"/>'
'</svg>'
)
parent_by_id = {
id(child): parent for parent in root.iter() for child in list(parent)
}
images = list(root.iter('{http://www.w3.org/2000/svg}image'))
nested = SVGQualityChecker._image_frame_geometry(images[0], root, parent_by_id)
plain = SVGQualityChecker._image_frame_geometry(images[1], root, parent_by_id)
self.assertEqual(nested, (180.0, 180.0, 0.24, 0.16))
self.assertEqual(plain, (405.0, 720.0, 1.0, 1.0))
# A 900x1600 source: the 0.24x0.16 crop is 216x256 px, so a square
# frame distorts it, while the plain 405x720 frame keeps 9:16 exactly.
self.assertGreater(
SVGQualityChecker._stretch_deviation(nested, (900, 1600)), 0.1
)
self.assertAlmostEqual(
SVGQualityChecker._stretch_deviation(plain, (900, 1600)), 0.0
)
self.assertIsNone(SVGQualityChecker._stretch_deviation(nested, None))
@@ -124,7 +124,7 @@ With an active template workspace, append exactly one line after the §I table
| --- | --- |
```
Preserve Title/Body characters and resolved stacks; omit a blank Typography upgrade and never place it in a stack. Each justified recurring family override adds its role to Font Plan plus `- **<Role> stack**: <complete ordered stack>` (roles: `Annotation`, `Footer`, `Footnote`, `Data`, `Emphasis`, `Quote`, `Code` — only recurring, intentional differences; a non-locked `Role rationale` only for an extra family); never collapse distinct Title/Body stacks or drop a declared role. Each Font Size Hierarchy value is a deck-wide role anchor; Executor's band and display exception are [`executor-base.md`](../references/executor-base.md) §2.1, so every recurring role is named here. Record every recurring palette role and size anchor the plan establishes, never one-off garnish. For confirmed custom directions add `Mode References`, `Mode Behavior`, `Visual Style References`, and `Visual Style Behavior` under Theme Style as applicable. `Stroke Width` under §VI only for a stroke library. `simple-icons` rows follow the brand-mark rule in the [icon README](../templates/icons/README.md). The §VI table records the synced SVG pool and, at `complete` depth, broad scenarios — never page placement; leave it empty when no bundled or brand icons are prepared. Other prepared SVGs under the project `icons/` remain usable without entering that selection. Illustrated icons are AI image resources: their sheet and placed slice rows belong in §VIII, and only placed slices project to `spec_lock.md images`.
Preserve Title/Body characters and resolved stacks; omit a blank Typography upgrade and never place it in a stack. Each justified recurring family override adds its role to Font Plan plus `- **<Role> stack**: <complete ordered stack>` (roles: `Display`, `Annotation`, `Footer`, `Footnote`, `Data`, `Emphasis`, `Quote`, `Code` — only recurring, intentional differences; `Display` covers hero numerals and oversized titles that leave the Title family; a non-locked `Role rationale` only for an extra family); never collapse distinct Title/Body stacks or drop a declared role. Each Font Size Hierarchy value is a deck-wide role anchor; Executor's band and display exception are [`executor-base.md`](../references/executor-base.md) §2.1, so every recurring role is named here. Record every recurring palette role and size anchor the plan establishes, never one-off garnish. For confirmed custom directions add `Mode References`, `Mode Behavior`, `Visual Style References`, and `Visual Style Behavior` under Theme Style as applicable. `Stroke Width` under §VI only for a stroke library. `simple-icons` rows follow the brand-mark rule in the [icon README](../templates/icons/README.md). The §VI table records the synced SVG pool and, at `complete` depth, broad scenarios — never page placement; leave it empty when no bundled or brand icons are prepared. Other prepared SVGs under the project `icons/` remain usable without entering that selection. Illustrated icons are AI image resources: their sheet and placed slice rows belong in §VIII, and only placed slices project to `spec_lock.md images`.
When §VIII contains any `Acquire Via: ai` row, add under §III:
@@ -154,7 +154,7 @@ A custom rendering adds `Image Rendering Behavior`, and `Image Rendering Referen
§VII lists at most one `chart|table` reference per page (canonical `family/key` plus semantic Usage; omit when empty; legacy rows stay readable, new specs use four columns). §IX owns child visuals and unmatched fallbacks in `Visualization`; qualitative relationships live only on the `Relationships` line; Layout/Deck alone owns reusable PowerPoint structure. In §IX `Visualization`, key every independent data chart or pure text-grid table in `kebab-case` and add one `Native-ready` map `<key>=yes|no; ...``yes` by default, `no` only when the native payload cannot express the object; qualitative relationships and incidental microvisuals stay unkeyed.
§VIII authors every planned or required resource from the confirmed source boundary: one concise non-empty `Image pattern` suggestion in ordinary language (optionally citing hierarchical ids from the layout library; an image-led `adaptive` row names the page job the image resolves next to the composition serving it); `Crop Policy` `adaptive` or `no-crop`; `Acquire Via` `ai`, `web`, `user`, `placeholder`, or `slice`; unresolved required assets kept as `Pending` or `Needs-Manual`; native formulas never enter it. `Image pattern` is per-resource — how several images relate on one page (repeated views, sequencing) is stated once in §IX `Images` as a Reference, never as duplicate rows; paint, overlay, and geometry are Executor's.
§VIII authors every planned or required resource from the confirmed source boundary: one concise non-empty `Image pattern` suggestion in ordinary language (optionally citing hierarchical ids from the layout library; an image-led `adaptive` row names the page job the image resolves next to the composition serving it); `Crop Policy` `adaptive` or `no-crop`; `Acquire Via` `ai`, `web`, `user`, `placeholder`, or `slice`; `Type` `Source` for an original that only feeds prepared derivatives (unplaced, never locked, at least one `Derived from` child) and `Illustration Sheet` for an AI slice parent; unresolved required assets kept as `Pending` or `Needs-Manual`; native formulas never enter it. `Image pattern` is per-resource — how several images relate on one page (repeated views, sequencing) is stated once in §IX `Images` as a Reference, never as duplicate rows; paint, overlay, and geometry are Executor's.
### 2.4 Complete page roster and notes
@@ -2,8 +2,8 @@
"sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"commit": "4c43e943d7998be45bbb5a2606e095bbf1ffaa8a",
"commit": "71e50952fbb7eda2c992660d36cd58671a2edf42",
"adapter": "claude-skill",
"sourcePath": "skills/shadcn",
"syncedAt": "2026-09-02T16:00:01Z"
"syncedAt": "2026-09-03T02:28:39Z"
}