Claude Skill

lov-riso-portrait

把单人照片用 gpt-image-2 重绘成身份保真的 Riso 头像,并检查五官、手指、饰品与圆形裁切。Use when the user asks“做成 Riso 人像”“生成孔版印刷头像”or “create a Riso portrait”。

LLM Mart · 0 points · 9 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lovstudio-skills-skills_riso-portrait-0b16007.zip · 891 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/riso-portrait
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
Git git clone https://github.com/lovstudio/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.

README

Riso 人像 · Riso Portrait

Version

把单人照片用 gpt-image-2 重绘成身份保真的 Riso 头像,并在交付前检查五官、手指、 饰品与圆形头像裁切。它要求模型直接完成画面重组,不使用程序化滤镜冒充 Riso。

安装

npx skills add lov-riso-portrait -g -y

使用

上传一张清晰的单人照片,然后说:

使用 Riso 人像 Skill 处理这张照片。保持人物身份、脸部结构、发型、视线和服装不变,
裁成适合圆形头像的近景,使用朱红与深青主墨色,并保留纸张颗粒、网点和轻微套印偏移。

第一轮完成后,可以只修一个事实:

只修正右上方的手,必须是五根手指;其他人物特征、动作、构图和 Riso 风格保持不变。

结果边界

  • 输出:身份保真的方形 Riso 头像 PNG,可选原图/结果或迭代对照图。
  • 模型:必须使用 gpt-image-2 做图像编辑。
  • 验收:人物识别、五官、手指、饰品、姿势、视线和圆形裁切。
  • 不包含:程序化双色滤镜、照片后处理、Logo/文字海报、公开托管或模型调用额度。

用户 Profile

skill.yaml 声明 user-profile/v1,可以复用用户直接保存的默认配色、头像裁切和纸张 倾向。照片、人物身份信息、临时 Prompt、访问凭据和生成结果不会写入 Profile。

可信度与案例

  • skill-card.yaml / skill-card.md:用途、依赖、风险、输出与维度证据。
  • cases/cases.json:真实原图到 Riso 结果,以及四指到五指的局部修正案例。
  • pricing-card.yaml:免费入口的价值依据、交付边界与复评条件。
  • references/skill-composition.md:与职业照、通用生图和风格分析能力的边界。

质量门

python3 scripts/validate_skill.py .

依赖

  • 支持图片查看与编辑的 Agent 运行时
  • gpt-image-2
  • Python 3.8+ 与 PyYAML,仅用于 Profile 与源码校验

License

MIT

Skill manifest

Riso 人像 · Riso Portrait

把一张单人照片直接重绘成适合头像使用的 Riso 插画:人物仍然一眼可认,画面使用 有限色墨、网点、纸张颗粒、粗线条和轻微套印偏移,而不是给原照片叠一层复古滤镜。

Triggers

Activate when

  • 用户说“把这张照片做成 Riso 人像”“生成孔版印刷风格头像”“做成双色网点头像”。
  • 用户要求保留人物身份、姿势和衣服特征,同时改成 Riso、Risograph 或孔版印刷视觉。
  • The user asks to “create a Riso portrait”, “turn this photo into a Risograph avatar”, or “make an identity-preserving Riso profile picture”.

Do not activate when

  • 用户只想磨皮、提亮、换背景或制作职业照;使用 lov-professional-portrait。
  • 用户要从多张样图提炼任意视觉风格,而不是生成 Riso 人像;使用风格分析能力。
  • 用户要制作海报、信息图或带文字的传播物料;使用相应设计 Skill。
  • 当前运行时无法调用 gpt-image-2 做图像编辑;不得用 Canvas、Sharp、CSS、双色映射或普通滤镜伪装成同等结果。

User Profile (cross-session)

每次运行读取 skill.yaml 声明的 user-profile/v1,按当前请求、项目上下文、 skills.lov-riso-portrait.records、共享 Preferences 和安全默认值解析配色与裁切偏好。 只有用户直接声明的长期偏好才通过 scripts/profile_store.py record --confirm 保存; 照片、人物身份信息、临时 Prompt、访问凭据和生成结果不得写入 Profile。

Skill Group Composition

运行前读取 references/skill-composition.md。本 Skill 独占“身份保真的 Riso 头像” 这一结果;相邻能力只通过原图或完成后的 PNG 可选交接,不构成隐藏依赖。

Implementation paths are alternatives

读取 references/implementation-options.md。程序化滤镜、生成模型重绘和 AI 参数分析 结合确定性渲染是三种并列方案,不是必须依次执行的三个步骤。本 Skill 的默认交付仍是 gpt-image-2 直接重绘,因为它最能重新组织人物、色块、线条和网点;只有用户要求 研究实现方式、追求参数可控或批量一致性时,才讨论其他方案,不能把双色滤镜冒充默认 Riso 人像结果。

Workflow (MANDATORY)

Step 0: Resolve root and runtime

  1. 解析 Skill 根目录,读取 skill.yaml、references/riso-art-direction.md、 references/quality-gate.md、references/implementation-options.md 与 references/skill-composition.md。
  2. 确认运行时能查看输入图片,并能用 gpt-image-2 进行真正的栅格图像编辑。
  3. 若模型不可用,明确停止并说明缺少 gpt-image-2;不能静默替换为程序化滤镜、 其他模型或只交付 Prompt。

Step 1: Inspect the source as the identity reference

  • 把用户上传的照片视为编辑目标和人物身份的唯一事实来源。
  • 确认输入是用户选择的原始照片或经核验的权威原图,而不是既有 Riso 结果、聊天缩略 图、被错误裁切的中间产物。多张候选图时逐张记录来源与角色,不从成品反推原图。
  • 检查脸型、五官比例、发型轮廓、神态、视线、姿势、衣服、饰品、手持物和手指。
  • 识别图片是否适合头像裁切:人脸是否清晰,发顶是否完整,视线前方是否有留白。
  • 多人照片默认不执行;先请用户明确主角或提供单人照片。
  • 只裁头部或头像用途时,先比较圆形裁切后的辨识度、视线留白、背景噪音和缩略图对比, 再决定源图;不能只按全图“最好看”排序。
  • 不把照片、结果或人物信息公开上传,除非用户另行授权。

Step 2: Resolve the smallest sufficient brief

默认输出为 1:1 近景头像,适配圆形裁切。使用深炭黑、青绿、朱红和暖纸色;保留 纸张颗粒、网点、粗线条、不完全均匀的墨边与轻微套印偏移。当前请求可以覆盖配色、 景别或背景,但不能无声改变人物身份、年龄、姿势、服装或饰品。

只在缺失信息会明显改变人物或头像用途时问一个问题;普通配色与裁切由安全默认值补齐。

Step 3: Build the identity-locked edit prompt

读取 references/riso-art-direction.md,让 Prompt 明确包含:

  1. style-transfer 用例和 1:1 social profile avatar 资产类型;
  2. 输入图是 edit target 和 identity reference;
  3. 人物身份、姿势、视线、服装和关键物件保持不变;
  4. Riso 的有限色墨、网点、纸张、手绘线条和轻微套印偏移;
  5. 圆形头像安全区、完整发型轮廓和视线前方留白;
  6. 禁止照片感、塑料皮肤、3D、普通动漫化、文字、Logo、水印和装饰边框。

用户的补充只作为 Creator note 加在模板末尾,不得覆盖身份锁和事实约束。

Step 4: Edit directly with gpt-image-2

  • 把原图作为 edit target 传给 gpt-image-2,使用高输入保真度和高质量方图输出。
  • 让模型自己重组线稿、色块、网点与纸张关系;不在生成后追加灰度分版、双色映射、 网角、噪点或套印滤镜。
  • 非破坏性保存,建议使用“原文件名-riso-portrait-v1.png”。
  • 第一轮只生成一个主版本,先验收再决定是否需要局部修正。

Step 5: Inspect at three scales

按 references/quality-gate.md 检查:

  1. 全图:人物、姿势、衣服和画面重心是否与原图一致;
  2. 脸部近看:眼睛、鼻子、嘴唇、下颌、发际线和表情是否仍像本人;
  3. 聊天列表与圆形裁切:缩小后是否仍可识别,发顶、下巴和视线是否被边缘吃掉。

另外逐项数清可见手指,检查耳饰、眼镜、项链、麦克风等高风险细节。整体氛围漂亮 不能替代事实检查。

Step 6: Correct one fact at a time

  • 身份漂移:重新强调脸型、五官比例、发型和神态,减少风格自由度。
  • 太像滤镜:要求重新组织线稿与色块,禁止保留照片式连续明暗。
  • 手指、饰品或物件错误:只修一个明确事实,并重复“其他人物特征、动作和风格不变”。
  • 常识性错误:回到源图和真实世界事实确定正确数量、结构与空间关系,再做局部修正; 不完全信任生成模型,也不因一个局部错误重做已经验收的整张图。
  • 裁切不适合头像:只调整景别与安全区,不重做人物。

每轮只解决一个问题。修正后重新执行三尺度检查,避免修好手指却改坏脸。

Step 7: Deliver

  • 在运行时支持时直接展示最终 PNG,并报告保存路径。
  • 简要说明采用的配色、裁切和有意修正;同时确认身份、姿势与关键物件被保留。
  • 用户要求时另做原图/结果对照或迭代过程图,但不要把标签、水印或营销文字烘焙进头像。
  • 过程图只展示真实发生且视觉上不同的阶段;删除重复结果,不补造畸形帧,并让局部 问题在统一尺寸的小图中清楚可见。

Completion Criteria

  • 缩到头像大小仍能认出同一个人。
  • 画面是重新设计的 Riso 插画,不是带颗粒的照片或双色滤镜。
  • 五官、可见手指、饰品、衣服、姿势与视线没有事实错误。
  • 圆形裁切不会切掉发顶、下巴或视线方向。
  • 原图保持不变,最终 PNG 路径与模型边界已报告。

Dependencies

  • 可查看本地或上传图片的 Agent 运行时
  • 可执行图像编辑的 gpt-image-2
  • 生成服务可能产生模型调用费用;Skill 本身免费
Files (skills)
  • cases
    • artifacts
      • avatar-source-and-riso.jpg 630.8 KB · in bundle
      • five-finger-correction.jpg 256.4 KB · in bundle
    • cases.json 1.9 KB
      [
        {
          "type": "case",
          "title": "Three avatar candidates redrawn with one Riso contract",
          "description": "Three authorized single-person photos with different poses, hair and backgrounds were each redrawn through gpt-image-2. The comparison verifies that identity, gaze and clothing cues survive while the visual medium changes to limited inks, halftone and paper grain.",
          "input": {
            "items": [
              "Three authorized source portraits supplied for avatar selection",
              "Square avatar use with identity, pose, gaze and clothing locked"
            ]
          },
          "prompt": "使用 Riso 人像 Skill 处理这些照片。保持人物身份、脸部结构、发型、视线和服装不变,裁成适合圆形头像的近景,使用朱红与深青主墨色,并保留纸张颗粒、网点和轻微套印偏移。",
          "output": {
            "items": [
              "cases/artifacts/avatar-source-and-riso.jpg",
              "A 2 by 3 source/result comparison with three recognizable Riso portraits"
            ]
          },
          "cover": "cases/artifacts/avatar-source-and-riso.jpg"
        },
        {
          "type": "case",
          "title": "Targeted correction from four fingers to five",
          "description": "An authorized event portrait already had a strong Riso treatment, but the raised hand contained four fingers. A single-fact correction restored five fingers while preserving the person, pose, microphone, clothing and style.",
          "input": {
            "items": [
              "Authorized event source photo",
              "First Riso output with an incorrect raised hand"
            ]
          },
          "prompt": "只修正右上方的手,必须是五根手指;其他人物特征、动作、构图和 Riso 风格保持不变。",
          "output": {
            "items": [
              "cases/artifacts/five-finger-correction.jpg",
              "Source, four-finger first pass and verified five-finger correction with detail crops"
            ]
          },
          "cover": "cases/artifacts/five-finger-correction.jpg"
        }
      ]
      
  • references
    • implementation-options.md 1.7 KB
      # Riso 人像的三种实现路径
      
      这三种路径解决的是不同目标,彼此并列,不使用“先……再……”把它们写成固定流程。
      
      ## 1. 程序化滤镜
      
      对原图做主体分割、调色、阈值、网点、纸张纹理和套印偏移。优点是速度快、参数稳定、
      可批量复现;缺点是仍以原像素为基础,难以重新设计线条、色块、负空间和人物轮廓。
      它适合“可控的 Riso-like 图像处理”,不能冒充生成模型完成的插画重绘。
      
      ## 2. 生成模型重绘
      
      把原图作为 edit target 与 identity reference,由 `gpt-image-2` 重组线稿、有限色墨、
      网点与纸张关系。它更容易得到完整的插画语言,也是本 Skill 默认路径;代价是结果存在
      随机性,可能产生脸部漂移、手指数目、饰品或物件等常识性错误,因此必须逐项验收。
      
      不要完全信任生成模型。发现错误时以源图和真实世界事实为准,只修错误区域,并明确
      要求其他身份、动作、构图与风格保持不变。
      
      ## 3. AI 分析参数 + 确定性渲染
      
      先让视觉模型分析源图并输出结构化参数,再由程序化管线确定性执行。建议参数至少包括:
      
      - 主体蒙版、背景处理方式与边缘羽化;
      - 墨色数量与具体色值;
      - 阈值、对比度、亮暗分区与网点密度;
      - 线条强度、纸张纹理、套印方向和偏移量;
      - 头像安全区、裁切中心与视线留白。
      
      该路径把大模型的图像理解与程序化渲染的可控性结合起来,适合批量一致性和参数复现,
      也可能减少生成模型的解剖错误。它目前是值得探索的研究方向,不得在没有真实实现与
      验收证据时写成已经完成或优于默认重绘。
      
    • quality-gate.md 1.4 KB
      # Riso Portrait Quality Gate
      
      ## Identity
      
      - The face is immediately recognizable as the same person at full size and avatar size.
      - Face shape, eyes, nose, lips, jaw, apparent age, expression and skin-tone family remain stable.
      - Hair silhouette, parting and visible hair accessories remain consistent with the source.
      
      ## Scene facts
      
      - Pose, gaze direction, clothing, jewelry, glasses and held objects are preserved.
      - Every visible hand has the correct number and arrangement of fingers.
      - No extra people, limbs, accessories, text, Logo or watermark were introduced.
      
      ## Riso medium
      
      - The image uses a limited spot-color palette rather than continuous photographic color.
      - Shadows are expressed through halftone or graphic shapes, not a gray photo layer.
      - Paper grain, imperfect ink edges and registration offset are visible but restrained.
      - The result reads as a hand-drawn editorial Riso portrait, not anime, 3D art or a filtered photo.
      
      ## Avatar crop
      
      - The square frame leaves the full hair silhouette and chin intact.
      - A centered circular crop keeps the face and gaze readable.
      - The direction in front of the gaze retains enough breathing room.
      - At chat-list size, facial identity and the main ink contrast remain legible.
      
      ## Correction gate
      
      If any check fails, make one targeted correction and repeat all four groups. Do not accept an
      attractive overall image as evidence that anatomy, identity or crop is correct.
      
    • riso-art-direction.md 2.5 KB
      # Riso Portrait Art Direction
      
      ## Accepted visual contract
      
      Use the uploaded image as both the edit target and the identity reference. Ask `gpt-image-2`
      to translate the visual medium while keeping the person and scene facts stable.
      
      The prompt should express this contract in natural language:
      
      ```text
      Use case: style-transfer
      Asset type: 1:1 social profile avatar
      Input image: The uploaded image is the edit target and identity reference.
      Primary request: Turn the main subject into a friendly hand-drawn Riso cartoon portrait with bold black outlines, limited spot colors, halftone texture and a simple rounded card background. Preserve identity and key features.
      Style/medium: authentic hand-printed Risograph portrait, flat graphic shapes, visible paper grain, halftone dots, bold imperfect ink edges, subtle 4 px registration offset; refined editorial illustration rather than a generic digital filter.
      Composition/framing: square close head-and-shoulders portrait optimized for a circular avatar; keep the source pose and gaze direction, preserve the full hair silhouette, and leave comfortable space in front of the gaze.
      Color palette: deep charcoal #182A2D, teal #087E8B, vermilion #EF542F, warm paper #F5DFBF.
      Constraints: Preserve the exact person's recognizable face shape, eyes, nose, lips, expression, hairstyle, skin proportions, pose, clothing cues, accessories and held objects. Keep identity stable while translating only the visual medium. No facial beautification, age change, pose change, extra accessories or extra people.
      Avoid: photorealism, anime style, plastic skin, glossy 3D rendering, text, logos, watermark, white circular cutout, ornate frame.
      ```
      
      Append a short `Creator note` only when the user supplies a real preference, such as a cleaner
      background, a specific two-ink palette, or a tighter crop. The note cannot cancel the identity,
      anatomy, composition, or no-text constraints.
      
      ## Why direct redraw matters
      
      Riso portrait quality comes from decisions about flat shapes, line weight, ink coverage, negative
      space and halftone shadows. A post-processing pipeline can recolor pixels and add dots, but it
      cannot reliably redesign those relationships. Do not add Canvas, Sharp, CSS, color-separation or
      noise passes after generation.
      
      ## Iteration contract
      
      - One correction per pass.
      - Repeat every invariant in a correction Prompt.
      - When the problem is local, identify the exact region and fact.
      - Never describe a new generation as fixed until the corrected detail has been visually inspected.
      
    • skill-card-standard.md 660 B
      # LovStudio Skill Card standard
      
      `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards:
      description, owner, license/terms, use case, deployment, requirements,
      risks/mitigations, references, output contract, version, and ethical
      considerations. LovStudio adds evidence that helps a user decide whether the
      Skill is credible:
      
      1. A real user case with Input → Prompt → Output.
      2. A dimension map with named evidence, not an unexplained score.
      3. A pricing basis, including the free boundary and review trigger.
      4. Explicit paid and free distribution states.
      
      Never claim a case, score, channel, or price that has not been verified.
      
    • skill-composition.md 1.9 KB
      # Skill Group Composition
      
      ## Nearby Skills Inspected
      
      - `lov-professional-portrait`:保留照片媒介,负责职业照精修、提亮、去帽和背景整理;
        它不生成 Riso 插画,因此不是重叠实现。
      - `lov-image-creator`:通用生图、代码渲染和 Prompt 工程框架;它可以提供图像工具,
        但不拥有身份保真的 Riso 头像验收。
      - `lov-style-clone`:从文章提炼写作文风并改写文本;输入输出都不是人像图片,不组合。
      - 运行中的 Imagine `Riso 人像工坊`:产品内的真实实现证据,使用 `gpt-image-2`、
        高输入保真度和同一视觉基准;它不是安装式 sibling Skill。
      
      ## Atomic Handoffs
      
      - 上游输入由用户或照片选择能力提供一张获得授权的单人图片;本 Skill 从身份检查开始负责。
      - 核心原子是 `lov-riso-portrait`:输入原图和最小 brief,输出通过人物、Riso 媒介、
        事实细节与头像裁切验收的 PNG。
      - 下游 `lov-image-decorator`、文章排版、社交发布或头像上传能力可以消费最终 PNG;
        它们不能替代本 Skill 的身份与解剖检查。
      - `lov-professional-portrait` 可在用户明确要求时先修复曝光或背景,但其输出必须重新
        作为本 Skill 的唯一 identity reference,不能在两项能力之间隐式混合身份。
      
      ## Overlap Decisions
      
      没有发现拥有同一结果契约的独立 Skill。通用生图不能保证采用 `gpt-image-2`,职业照
      保持照片媒介,Imagine 则是产品运行实现。新 Skill 复用已验收的视觉契约和质量门,
      不复制 Imagine 的任务持久化、鉴权或服务端代码。
      
      ## Composition Decision
      
      本源是 Single Skill。原图检查、Prompt 约束、直接重绘、三尺度验收和局部修正共同
      服务一个头像结果,拆成 Kit 会增加身份漂移风险。相邻能力均通过图片文件可选交接。
      
    • user-profile.md 2.7 KB
      # User Profile contract
      
      Every Skill created by Skill Creator declares `user-profile/v1` in `skill.yaml`.
      The contract connects independent sessions to one user-owned JSON Profile while
      keeping the Skill source portable across users and brands.
      
      ## Shared shape
      
      The host supplies the Profile through `SKILL_PROFILE_PATH` (or the runtime's
      configured profile path). The stable shared scopes are:
      
      - `user`: user identity, language, timezone, and other personal working defaults.
      - `brand`: public brand facts, site, logo, tone, profile, and design guidance.
      - `workspace`: project roots and output locations.
      - `preferences`: shared preference values when the host stores them in the Profile.
      - `skills.<skill_id>.profile`: Skill-specific defaults.
      - `skills.<skill_id>.records`: durable decisions and preferences learned from
        direct user statements for this Skill.
      
      The Profile may also use the runtime's canonical `identity` fields. Manifest
      field aliases bridge `identity.*` and the portable `user.*` / `brand.*` names.
      
      ## Read on every run
      
      1. Read the current request and project context.
      2. Read the shared Profile and the `skills.<skill_id>` namespace.
      3. Resolve values in this order: current request, project context, Skill records,
         shared preferences, shared user/brand Profile, safe defaults.
      4. Keep `profile_scope` and field provenance available for the final result.
      
      Do not copy resolved personal paths, brand values, or private records into the
      committed Skill source.
      
      ## Persist directly stated values
      
      When the user explicitly gives a value meant to survive later sessions, save it
      immediately after the user statement and report the canonical path:
      
      ```bash
      python3 scripts/profile_store.py record \
        --skill-id lov-example \
        --path records.subtitle_level \
        --value '"cet4"' \
        --confirm
      ```
      
      For shared facts, use `--path brand.<field>` or `--path user.<field>`. The
      script writes JSON atomically, preserves unrelated Profile data, increments a
      numeric Profile revision when present, and never echoes the stored value.
      
      Inferred information, credentials, tokens, cookies, and secret-like fields stay
      out of durable records. If the user has not stated that a value should persist,
      keep it in the current request context.
      
      ## Read the connected context
      
      ```bash
      python3 scripts/profile_store.py read \
        --skill-id lov-example \
        --pretty
      ```
      
      The result contains `user`, `brand`, `workspace`, `preferences`, `skill`, and
      `records` scopes. A host using `skill-runtime/v1` also returns the same binding
      as `profile_scope` and `profile_contract`.
      
      ## Compatibility
      
      `--user-config` remains accepted by the Creator as a compatibility flag for old
      invocations. The Profile contract is now always generated; users do not choose
      an initialization mode.
      
  • scripts
    • profile_store.py 8.3 KB
      #!/usr/bin/env python3
      """Read and persist a Skill's user-owned profile context.
      
      The command keeps shared identity and brand facts in the profile root while
      putting Skill-specific durable records under ``skills.<skill_id>.records``.
      Writes require an explicit confirmation flag and use an atomic replacement.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import sys
      import tempfile
      from pathlib import Path
      from typing import Any, Iterable
      
      
      USER_PROFILE_SCHEMA = "user-profile/v1"
      SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
      SAFE_ROOTS = {"user", "brand", "workspace", "preferences"}
      SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"}
      
      
      def config_dir() -> Path:
          configured = os.environ.get("SKILLS_CONFIG_DIR")
          if configured:
              return Path(os.path.expandvars(configured)).expanduser()
          xdg = os.environ.get("XDG_CONFIG_HOME")
          if xdg:
              return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills"
          return Path.home() / ".config" / "agent-skills"
      
      
      def profile_path(explicit: Path | None = None) -> Path:
          if explicit:
              return explicit.expanduser()
          configured = (
              os.environ.get("SKILL_PROFILE_PATH")
              or os.environ.get("SKILLS_PROFILE_PATH")
          )
          if configured:
              return Path(os.path.expandvars(configured)).expanduser()
          candidates = (
              Path.home() / ".lovstudio" / "skills" / "profile.json",
              Path.home() / ".skill-publisher" / "skills" / "profile.json",
              config_dir() / "profile.json",
          )
          return next((candidate for candidate in candidates if candidate.exists()), candidates[-1])
      
      
      def read_profile(path: Path) -> dict[str, Any]:
          if not path.exists():
              return {}
          try:
              value = json.loads(path.read_text(encoding="utf-8"))
          except (OSError, json.JSONDecodeError) as exc:
              raise ValueError(f"profile cannot be read: {path}: {exc}") from exc
          if not isinstance(value, dict):
              raise ValueError("profile root must be an object")
          return value
      
      
      def profile_skeleton(skill_id: str) -> dict[str, Any]:
          return {
              "schema": "skill-profile/v1",
              "profile_id": f"profile-{skill_id}",
              "revision": 1,
              "status": "draft",
              "identity": {},
              "purpose": {},
              "brand": {},
              "user": {},
              "workspace": {},
              "skills": {},
              "extensions": {},
          }
      
      
      def atomic_write(path: Path, value: dict[str, Any]) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          mode = 0o600
          if path.exists():
              mode = path.stat().st_mode & 0o777
          temporary_name: str | None = None
          try:
              with tempfile.NamedTemporaryFile(
                  "w",
                  encoding="utf-8",
                  dir=path.parent,
                  prefix=f".{path.name}.",
                  suffix=".tmp",
                  delete=False,
              ) as handle:
                  temporary_name = handle.name
                  handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
                  handle.flush()
                  os.fsync(handle.fileno())
              os.chmod(temporary_name, mode)
              os.replace(temporary_name, path)
          finally:
              if temporary_name:
                  temporary = Path(temporary_name)
                  if temporary.exists():
                      temporary.unlink()
      
      
      def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None:
          current: dict[str, Any] = root
          for part in parts[:-1]:
              child = current.get(part)
              if not isinstance(child, dict):
                  child = {}
                  current[part] = child
              current = child
          current[parts[-1]] = value
      
      
      def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]:
          if not SKILL_ID_RE.fullmatch(skill_id):
              raise ValueError("skill_id must be kebab-case")
          path = requested_path.strip().strip(".")
          if not path:
              raise ValueError("path is required")
          parts = [part for part in path.split(".") if part]
          if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts):
              raise ValueError("path contains an invalid segment")
          if any(part.casefold() in SENSITIVE_PARTS for part in parts):
              raise ValueError("profile records do not accept secret-like fields")
      
          if parts[0] == "records":
              target = ["skills", skill_id, "records", *parts[1:]]
              return target, ".".join(target)
          if parts[0] == "profile":
              target = ["skills", skill_id, "profile", *parts[1:]]
              return target, ".".join(target)
          if parts[:3] == ["skills", skill_id, "records"]:
              return parts, ".".join(parts)
          if parts[:3] == ["skills", skill_id, "profile"]:
              return parts, ".".join(parts)
          if parts[0] in SAFE_ROOTS:
              return parts, ".".join(parts)
          expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>"
          raise ValueError(f"path must use {expected}")
      
      
      def parse_value(raw: str) -> Any:
          try:
              return json.loads(raw)
          except json.JSONDecodeError:
              return raw
      
      
      def read_command(args: argparse.Namespace) -> dict[str, Any]:
          path = profile_path(args.profile)
          profile = read_profile(path)
          skills = profile.get("skills", {})
          if not isinstance(skills, dict):
              skills = {}
          skill = skills.get(args.skill_id, {})
          if not isinstance(skill, dict):
              skill = {}
          user = profile.get("user")
          if not isinstance(user, dict) or not user:
              user = profile.get("identity", {})
          if not isinstance(user, dict):
              user = {}
          brand = profile.get("brand", {})
          if not isinstance(brand, dict):
              brand = {}
          workspace = profile.get("workspace", {})
          if not isinstance(workspace, dict):
              workspace = {}
          preferences = profile.get("preferences", {})
          if not isinstance(preferences, dict):
              preferences = {}
          records = skill.get("records", {})
          if not isinstance(records, dict):
              records = {}
          return {
              "status": "ready",
              "schema": USER_PROFILE_SCHEMA,
              "skill_id": args.skill_id,
              "profile_path": str(path),
              "user": user,
              "brand": brand,
              "workspace": workspace,
              "preferences": preferences,
              "skill": skill,
              "records": records,
          }
      
      
      def record_command(args: argparse.Namespace) -> dict[str, Any]:
          if not args.confirm:
              raise PermissionError("record requires --confirm after the user has stated the value")
          parts, canonical_path = target_parts(args.skill_id, args.path)
          path = profile_path(args.profile)
          profile = read_profile(path)
          if not profile:
              profile = profile_skeleton(args.skill_id)
          set_path(profile, parts, parse_value(args.value))
          revision = profile.get("revision")
          if isinstance(revision, int) and not isinstance(revision, bool):
              profile["revision"] = revision + 1
          atomic_write(path, profile)
          return {
              "status": "saved",
              "schema": USER_PROFILE_SCHEMA,
              "skill_id": args.skill_id,
              "profile_path": str(path),
              "path": canonical_path,
          }
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path")
          subparsers = parser.add_subparsers(dest="command", required=True)
      
          read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context")
          read_parser.add_argument("--skill-id", required=True)
          read_parser.add_argument("--pretty", action="store_true")
      
          record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value")
          record_parser.add_argument("--skill-id", required=True)
          record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>")
          record_parser.add_argument("--value", required=True)
          record_parser.add_argument("--confirm", action="store_true")
      
          args = parser.parse_args()
          try:
              result = read_command(args) if args.command == "read" else record_command(args)
          except (OSError, PermissionError, ValueError) as exc:
              print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
              return 2
          indent = 2 if getattr(args, "pretty", False) else None
          print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent)))
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • validate_skill.py 26.2 KB
      #!/usr/bin/env python3
      """Validate a portable local Skill Publisher Skill source directory."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      from typing import Any, Iterable
      
      try:
          import yaml
      except ImportError:
          print(
              "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML",
              file=sys.stderr,
          )
          raise SystemExit(2)
      
      
      FRONTMATTER_KEYS = {
          "name",
          "description",
          "license",
          "compatibility",
          "allowed-tools",
          "depends_on",
          "metadata",
      }
      TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"}
      JUNK_NAMES = {"__pycache__", ".DS_Store"}
      JUNK_SUFFIXES = {".pyc", ".pyo"}
      SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"}
      SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
      NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
      MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)")
      SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)")
      CARD_STANDARD = "lovstudio/skill-card/v1"
      PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1"
      MANIFEST_SCHEMA = "skill-manifest/v1"
      RUNTIME_VERSION = "skill-runtime/v1"
      PROFILE_SCHEMA = "user-profile/v1"
      CONTENT_CLASSES = {
          "authored-prose",
          "microcopy",
          "verbatim",
          "deterministic-output",
      }
      
      
      class ValidationFailure(Exception):
          """Raised when source metadata cannot be parsed."""
      
      
      def read_text(path: Path) -> str:
          return path.read_text(encoding="utf-8")
      
      
      def compact_text(value: Any) -> str:
          return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else ""
      
      
      def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]:
          text = read_text(path)
          if not text.startswith("---\n"):
              raise ValidationFailure(f"{path}: missing YAML frontmatter")
          marker = text.find("\n---\n", 4)
          if marker < 0:
              raise ValidationFailure(f"{path}: frontmatter is not closed")
          try:
              data = yaml.safe_load(text[4:marker])
          except yaml.YAMLError as exc:
              raise ValidationFailure(
                  f"{path}: standard YAML parser rejected frontmatter: {exc}"
              ) from exc
          if not isinstance(data, dict):
              raise ValidationFailure(f"{path}: frontmatter must be a mapping")
          return data, text[marker + 5 :]
      
      
      def iter_files(root: Path) -> Iterable[Path]:
          for path in root.rglob("*"):
              if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
                  continue
              if path.is_file():
                  yield path
      
      
      def is_relative_to(path: Path, parent: Path) -> bool:
          try:
              path.relative_to(parent)
              return True
          except ValueError:
              return False
      
      
      def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None:
          try:
              data, body = split_frontmatter(path)
          except ValidationFailure as exc:
              errors.append(str(exc))
              return None
      
          unexpected = sorted(set(data) - FRONTMATTER_KEYS)
          if unexpected:
              errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}")
      
          name = compact_text(data.get("name"))
          if not NAME_RE.fullmatch(name) or len(name) > 64:
              errors.append(f"{path}: name must be kebab-case and at most 64 characters")
      
          description = compact_text(data.get("description"))
          if not 50 <= len(description) <= 200:
              errors.append(
                  f"{path}: description must contain 50-200 characters "
                  f"(found {len(description)})"
              )
      
          compatibility = compact_text(data.get("compatibility"))
          if not compatibility:
              errors.append(f"{path}: compatibility is required at the top level")
      
          depends_on = data.get("depends_on", [])
          if isinstance(depends_on, str):
              depends_on = [depends_on]
          if not isinstance(depends_on, list) or not all(
              isinstance(item, str) and NAME_RE.fullmatch(item) for item in depends_on
          ):
              errors.append(f"{path}: depends_on must be a list of exact Skill names")
      
          metadata = data.get("metadata")
          if not isinstance(metadata, dict):
              errors.append(f"{path}: metadata must be a mapping")
          else:
              if not compact_text(metadata.get("author")):
                  errors.append(f"{path}: metadata.author is required")
              if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))):
                  errors.append(f"{path}: metadata.version must use SemVer")
              tags = metadata.get("tags")
              if not isinstance(tags, list) or not tags or not all(
                  isinstance(tag, str) and tag.strip() for tag in tags
              ):
                  errors.append(f"{path}: metadata.tags must be a non-empty list")
              if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD:
                  errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}")
              content_class = compact_text(metadata.get("content_class"))
              if content_class and content_class not in CONTENT_CLASSES:
                  errors.append(
                      f"{path}: metadata.content_class must be one of "
                      f"{', '.join(sorted(CONTENT_CLASSES))}"
                  )
              if content_class in {"authored-prose", "microcopy"} and (
                  not isinstance(depends_on, list)
                  or "lov-branding-consistency" not in depends_on
              ):
                  errors.append(
                      f"{path}: {content_class} requires lov-branding-consistency"
                  )
              if content_class == "authored-prose":
                  authorship_reference = path.parent / "references" / "authorship-integrity.md"
                  if not authorship_reference.is_file():
                      errors.append(
                          f"{authorship_reference}: authored-prose contract is required"
                      )
                  if "references/authorship-integrity.md" not in body:
                      errors.append(
                          f"{path}: authored-prose must route to references/authorship-integrity.md"
                      )
      
          trigger_block = re.search(
              r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body
          )
          if not trigger_block:
              errors.append(f"{path}: add an explicit '## Triggers' section")
          else:
              block = trigger_block.group(1)
              if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3:
                  errors.append(f"{path}: add two activation examples and one non-trigger")
              if not re.search(r"[\u3400-\u9fff]", block):
                  errors.append(f"{path}: add a concrete Chinese trigger phrase")
              if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block):
                  errors.append(f"{path}: add a concrete English trigger phrase")
          if not re.search(
              r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$",
              body,
          ):
              errors.append(f"{path}: add explicit non-trigger conditions")
          if len(read_text(path).splitlines()) >= 500:
              errors.append(f"{path}: keep SKILL.md below 500 lines")
          if not body.strip():
              errors.append(f"{path}: body is empty")
          return data
      
      
      def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None:
          try:
              data = yaml.safe_load(read_text(path))
          except yaml.YAMLError as exc:
              errors.append(f"{path}: standard YAML parser rejected file: {exc}")
              return None
          if not isinstance(data, dict):
              errors.append(f"{path}: expected a YAML mapping")
              return None
          return data
      
      
      def validate_runtime_manifest(
          skill_root: Path, expected_skill_id: str, errors: list[str]
      ) -> None:
          manifest_path = skill_root / "skill.yaml"
          if not manifest_path.is_file():
              errors.append(f"{manifest_path}: user-profile runtime manifest is required")
              return
          data = load_yaml(manifest_path, errors)
          if data is None:
              return
          if data.get("schema") != MANIFEST_SCHEMA:
              errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}")
          if data.get("id") != expected_skill_id:
              errors.append(f"{manifest_path}: id must match {expected_skill_id}")
          version = compact_text(data.get("version"))
          if not SEMVER_RE.fullmatch(version):
              errors.append(f"{manifest_path}: version must use SemVer")
          if data.get("runtime") != RUNTIME_VERSION:
              errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}")
      
          context = data.get("context")
          if not isinstance(context, dict):
              errors.append(f"{manifest_path}: context must be a mapping")
              return
          profile = context.get("profile")
          preferences = context.get("preferences")
          interaction = context.get("interaction")
          if not isinstance(profile, dict):
              errors.append(f"{manifest_path}: context.profile must be a mapping")
          else:
              if profile.get("schema") != PROFILE_SCHEMA:
                  errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}")
              if profile.get("source") != "shared-profile":
                  errors.append(f"{manifest_path}: context.profile.source must be shared-profile")
              reads = profile.get("read")
              if not isinstance(reads, list) or not reads or not all(
                  isinstance(item, str) and item.strip() for item in reads
              ):
                  errors.append(f"{manifest_path}: context.profile.read must be a non-empty list")
              persist = profile.get("persist")
              if not isinstance(persist, dict):
                  errors.append(f"{manifest_path}: context.profile.persist must be a mapping")
              else:
                  if persist.get("enabled") is not True:
                      errors.append(f"{manifest_path}: profile persistence must be enabled")
                  if persist.get("namespace") != f"skills.{expected_skill_id}":
                      errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}")
                  if persist.get("records_path") != f"skills.{expected_skill_id}.records":
                      errors.append(f"{manifest_path}: persist.records_path must target Skill records")
                  if persist.get("write_policy") != "direct-user-statement":
                      errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement")
                  if persist.get("atomic") is not True:
                      errors.append(f"{manifest_path}: profile persistence must be atomic")
              fields = profile.get("fields")
              if not isinstance(fields, list) or not fields:
                  errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list")
              else:
                  validate_manifest_fields(manifest_path, "profile", fields, errors)
          if not isinstance(preferences, dict):
              errors.append(f"{manifest_path}: context.preferences must be a mapping")
          else:
              fields = preferences.get("fields")
              if not isinstance(fields, list):
                  errors.append(f"{manifest_path}: context.preferences.fields must be a list")
              else:
                  validate_manifest_fields(manifest_path, "preferences", fields, errors)
          if not isinstance(interaction, dict):
              errors.append(f"{manifest_path}: context.interaction must be a mapping")
          else:
              if not isinstance(interaction.get("ask_missing"), bool):
                  errors.append(f"{manifest_path}: interaction.ask_missing must be boolean")
              max_questions = interaction.get("max_questions")
              if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3:
                  errors.append(f"{manifest_path}: interaction.max_questions must be 1-3")
      
      
      def validate_manifest_fields(
          manifest_path: Path, source_name: str, fields: list[Any], errors: list[str]
      ) -> None:
          for index, field in enumerate(fields):
              label = f"{manifest_path}: context.{source_name}.fields[{index}]"
              if not isinstance(field, dict):
                  errors.append(f"{label} must be a mapping")
                  continue
              if not compact_text(field.get("path")):
                  errors.append(f"{label}.path is required")
              if not isinstance(field.get("required"), bool):
                  errors.append(f"{label}.required must be boolean")
              if not compact_text(field.get("question")):
                  errors.append(f"{label}.question is required")
              aliases = field.get("aliases", [])
              if isinstance(aliases, str):
                  aliases = [aliases]
              if not isinstance(aliases, list) or not all(
                  isinstance(alias, str) and alias.strip() for alias in aliases
              ):
                  errors.append(f"{label}.aliases must be a list of paths")
      
      
      def has_content(value: Any) -> bool:
          if isinstance(value, str):
              return bool(value.strip())
          if isinstance(value, list):
              return any(has_content(item) for item in value)
          if isinstance(value, dict):
              return any(has_content(item) for item in value.values())
          return value is not None
      
      
      def contains_placeholder(value: Any) -> bool:
          if isinstance(value, str):
              return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I))
          if isinstance(value, list):
              return any(contains_placeholder(item) for item in value)
          if isinstance(value, dict):
              return any(contains_placeholder(item) for item in value.values())
          return False
      
      
      def validate_card_bundle(skill_root: Path, errors: list[str]) -> None:
          card_path = skill_root / "skill-card.yaml"
          card_doc_path = skill_root / "skill-card.md"
          cases_path = skill_root / "cases" / "cases.json"
          pricing_path = skill_root / "pricing-card.yaml"
      
          for path in (card_path, card_doc_path, cases_path, pricing_path):
              if not path.is_file():
                  errors.append(f"{path}: required Skill trust-bundle file is missing")
      
          card = load_yaml(card_path, errors) if card_path.is_file() else None
          if card is not None:
              if card.get("schema") != CARD_STANDARD:
                  errors.append(f"{card_path}: schema must be {CARD_STANDARD}")
              required = (
                  "description", "owner", "license", "use_case", "deployment",
                  "requirements", "risks", "references", "output", "version",
                  "ethical_considerations", "dimensions", "pricing", "distribution",
              )
              for key in required:
                  if key not in card or not has_content(card.get(key)):
                      errors.append(f"{card_path}: required field '{key}' is missing or empty")
              dimensions = card.get("dimensions")
              if not isinstance(dimensions, list) or len(dimensions) < 3:
                  errors.append(f"{card_path}: dimensions must contain at least three named dimensions")
              else:
                  ids: set[str] = set()
                  for index, dimension in enumerate(dimensions):
                      label = f"{card_path}: dimensions[{index}]"
                      if not isinstance(dimension, dict):
                          errors.append(f"{label}: expected a mapping")
                          continue
                      dimension_id = compact_text(dimension.get("id"))
                      if not dimension_id or dimension_id in ids:
                          errors.append(f"{label}: id is required and must be unique")
                      ids.add(dimension_id)
                      for key in ("label", "description", "evidence"):
                          if not compact_text(dimension.get(key)):
                              errors.append(f"{label}: '{key}' is required")
              risks = card.get("risks")
              if not isinstance(risks, list) or not risks:
                  errors.append(f"{card_path}: risks must contain at least one risk and mitigation")
              else:
                  for index, risk in enumerate(risks):
                      if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")):
                          errors.append(f"{card_path}: risks[{index}] needs risk and mitigation")
              distribution = card.get("distribution")
              if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list):
                  errors.append(f"{card_path}: distribution must declare paid and free lists")
              if contains_placeholder(card):
                  errors.append(f"{card_path}: replace unresolved TODO or template placeholders")
      
          if card_doc_path.is_file():
              card_doc = read_text(card_doc_path)
              required_headings = (
                  "Description", "Owner", "License", "Use Case", "Deployment Geography",
                  "Requirements", "Known Risks", "References", "Skill Output",
                  "Skill Version", "Ethical Considerations", "User Cases",
                  "Dimension Map", "Pricing Basis", "Distribution",
              )
              for heading in required_headings:
                  if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc):
                      errors.append(f"{card_doc_path}: add the '{heading}' section")
              if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I):
                  errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders")
      
          if cases_path.is_file():
              try:
                  cases = json.loads(read_text(cases_path))
              except json.JSONDecodeError as exc:
                  errors.append(f"{cases_path}: invalid JSON: {exc}")
                  cases = []
              if not isinstance(cases, list) or not cases:
                  errors.append(f"{cases_path}: include at least one real user case")
              else:
                  for index, case in enumerate(cases):
                      label = f"{cases_path}: cases[{index}]"
                      if not isinstance(case, dict):
                          errors.append(f"{label}: expected a mapping")
                          continue
                      for key in ("title", "description", "input", "prompt", "output"):
                          if not has_content(case.get(key)):
                              errors.append(f"{label}: '{key}' is required")
                      if contains_placeholder(case):
                          errors.append(f"{label}: replace unresolved TODO or template placeholders")
                      for image_key in ("cover", "gallery"):
                          image_values = case.get(image_key, [])
                          if isinstance(image_values, str):
                              image_values = [image_values]
                          if not isinstance(image_values, list):
                              errors.append(f"{label}: '{image_key}' must be a string or list")
                              continue
                          for image in image_values:
                              if not isinstance(image, str) or not image.strip():
                                  errors.append(f"{label}: '{image_key}' contains an empty path")
                              elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file():
                                  errors.append(f"{label}: case asset does not exist: {image}")
      
          pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None
          if pricing is not None:
              if pricing.get("schema") != PRICING_CARD_SCHEMA:
                  errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}")
              for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"):
                  if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))):
                      errors.append(f"{pricing_path}: required field '{key}' is missing or empty")
              if contains_placeholder(pricing):
                  errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders")
      
      
      def validate_composition_reference(skill_root: Path, errors: list[str]) -> None:
          path = skill_root / "references" / "skill-composition.md"
          if not path.is_file():
              errors.append(f"{path}: required Skill group composition record is missing")
              return
          text = read_text(path)
          required_headings = (
              "Nearby Skills Inspected",
              "Atomic Handoffs",
              "Overlap Decisions",
              "Composition Decision",
          )
          for heading in required_headings:
              if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text):
                  errors.append(f"{path}: add the '{heading}' section")
          if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I):
              errors.append(f"{path}: replace unresolved TODO or template placeholders")
      
      
      def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None:
          manifest = root / "kit.yaml"
          if not manifest.exists():
              return
          data = load_yaml(manifest, errors)
          if data is None:
              return
          modules = data.get("modules")
          if not isinstance(modules, list) or not modules:
              errors.append(f"{manifest}: modules must be a non-empty list")
              return
          module_ids: set[str] = set()
          for index, module in enumerate(modules):
              label = f"{manifest}: modules[{index}]"
              if not isinstance(module, dict):
                  errors.append(f"{label}: expected a mapping")
                  continue
              module_id = compact_text(module.get("id"))
              skill_name = compact_text(module.get("skill"))
              relative = compact_text(module.get("path"))
              if not module_id or module_id in module_ids:
                  errors.append(f"{label}: id is required and must be unique")
              module_ids.add(module_id)
              module_path = (root / relative).resolve()
              if (
                  not relative
                  or not is_relative_to(module_path, root.resolve())
                  or not (module_path / "SKILL.md").is_file()
              ):
                  errors.append(f"{label}: missing module at '{relative}/SKILL.md'")
              if skill_name not in skill_names:
                  errors.append(f"{label}: unresolved child skill '{skill_name}'")
          pipelines = data.get("pipelines")
          if not isinstance(pipelines, dict) or not pipelines:
              errors.append(f"{manifest}: pipelines must be a non-empty mapping")
              return
          for pipeline, sequence in pipelines.items():
              if not isinstance(sequence, list) or not sequence:
                  errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list")
                  continue
              missing = [str(item) for item in sequence if item not in module_ids]
              if missing:
                  errors.append(
                      f"{manifest}: pipeline '{pipeline}' has unknown modules: "
                      + ", ".join(missing)
                  )
      
      
      def validate_local_references(root: Path, errors: list[str]) -> None:
          for path in iter_files(root):
              if path.suffix.lower() != ".md":
                  continue
              text = read_text(path)
              for raw in MARKDOWN_LINK_RE.findall(text):
                  target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0]
                  if (
                      not target
                      or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I)
                      or any(token in target for token in ("TODO", "{", "}"))
                  ):
                      continue
                  if not (path.parent / target).resolve().exists():
                      errors.append(f"{path}: broken local link '{target}'")
              skill_root = path.parent if path.name == "SKILL.md" else root
              for variable, target in SKILL_PATH_RE.findall(text):
                  if "TODO" in target:
                      continue
                  base = skill_root if variable == "SKILL_DIR" else root
                  resolved = (base / target.rstrip(".,;:)")).resolve()
                  if not is_relative_to(resolved, root.resolve()) or not resolved.exists():
                      errors.append(f"{path}: missing required resource '${variable}/{target}'")
      
      
      def validate_hygiene(root: Path, errors: list[str]) -> None:
          private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)")
          for path in root.rglob("*"):
              if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
                  continue
              if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES:
                  errors.append(f"{path}: generated/cache artifact must not ship")
          for path in iter_files(root):
              if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py":
                  continue
              text = read_text(path)
              if private_path.search(text):
                  errors.append(f"{path}: contains a private absolute user path")
              if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text):
                  errors.append(f"{path}: unresolved TODO placeholder")
          for relative in ("workbuddy", "scripts/build_workbuddy.py"):
              if (root / relative).exists():
                  errors.append(
                      f"{root / relative}: platform distribution artifacts belong to skill-publish"
                  )
      
      
      def validate_source(root: Path, errors: list[str]) -> None:
          root_skill = root / "SKILL.md"
          skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))]
          if not root_skill.is_file():
              errors.append(f"{root_skill}: file is required")
              return
          parsed: list[tuple[Path, dict[str, Any]]] = []
          for path in skill_files:
              data = validate_skill_file(path, errors)
              if data:
                  parsed.append((path, data))
          names = {compact_text(data.get("name")) for _, data in parsed}
          if len(names) != len(parsed):
              errors.append(f"{root}: every embedded Skill must have a unique name")
          for path, data in parsed:
              metadata = data.get("metadata")
              if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD:
                  validate_card_bundle(path.parent, errors)
              validate_composition_reference(path.parent, errors)
              validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors)
          validate_kit(root, names, errors)
      
          readme = root / "README.md"
          if not readme.is_file():
              errors.append(f"{readme}: file is required")
          elif parsed:
              metadata = parsed[0][1].get("metadata")
              version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else ""
              if version and f"version-{version}-" not in read_text(readme):
                  errors.append(f"{readme}: version badge must match {version}")
      
          validate_hygiene(root, errors)
          validate_local_references(root, errors)
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("path", type=Path, help="Local Skill source directory")
          args = parser.parse_args()
          root = args.path.expanduser().resolve()
          if not root.is_dir():
              print(f"ERROR: directory does not exist: {root}", file=sys.stderr)
              return 2
          errors: list[str] = []
          validate_source(root, errors)
          if errors:
              print(f"FAILED: {len(errors)} issue(s)")
              for error in errors:
                  print(f"- {error}")
              return 1
          print(f"PASSED: source validation ({root})")
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • .gitignore 84 B · in bundle
  • CHANGELOG.md 391 B
    # Changelog
    
    ## [0.2.0] - 2026-09-01
    
    ### Added
    
    - 补充三种并列实现路径、原图真源与常识性错误修正契约。
    
    ## 0.1.0
    
    - Added an identity-preserving `gpt-image-2` Riso portrait workflow.
    - Added the accepted art direction, three-scale quality gate and one-fact correction contract.
    - Added two authorized real cases, a free-entry Pricing Card and Skill Card evidence.
    
  • LICENSE 1 KB · in bundle
  • pricing-card.yaml 1.1 KB
    schema: lovstudio/pricing-card/v1
    version: "0.1.0"
    model: free
    currency: CNY
    list_price_cny: 0
    value_anchor: "A reusable identity-preserving Riso portrait workflow with a proven gpt-image-2 art direction, avatar crop rules, anatomy checks, and targeted correction contract."
    basis: "The Skill is an installable instruction and quality-gate layer; users supply their own image-generation runtime and model usage. Free publication supports the accompanying educational article and builds broader evidence across faces, poses, clothing and image providers."
    boundary: "Includes source inspection, gpt-image-2 prompt construction, direct Riso redraw, identity and anatomy review, circular-avatar crop review, and one-fact-at-a-time correction. Excludes model credits, hosted generation, photo rights clearance, public upload, text or Logo poster design, and guaranteed deterministic output."
    review_trigger: "Reassess after 100 verified external uses, five non-synthetic public cases across varied subjects, or the addition of hosted generation, proprietary presets, batch processing, team review, or material support cost."
    confidence: internal
    
  • README.md 2 KB
    # Riso 人像 · Riso Portrait
    
    ![Version](https://img.shields.io/badge/version-0.2.0-CC785C)
    
    把单人照片用 `gpt-image-2` 重绘成身份保真的 Riso 头像,并在交付前检查五官、手指、
    饰品与圆形头像裁切。它要求模型直接完成画面重组,不使用程序化滤镜冒充 Riso。
    
    ## 安装
    
    ```bash
    npx skills add lov-riso-portrait -g -y
    ```
    
    ## 使用
    
    上传一张清晰的单人照片,然后说:
    
    ```text
    使用 Riso 人像 Skill 处理这张照片。保持人物身份、脸部结构、发型、视线和服装不变,
    裁成适合圆形头像的近景,使用朱红与深青主墨色,并保留纸张颗粒、网点和轻微套印偏移。
    ```
    
    第一轮完成后,可以只修一个事实:
    
    ```text
    只修正右上方的手,必须是五根手指;其他人物特征、动作、构图和 Riso 风格保持不变。
    ```
    
    ## 结果边界
    
    - 输出:身份保真的方形 Riso 头像 PNG,可选原图/结果或迭代对照图。
    - 模型:必须使用 `gpt-image-2` 做图像编辑。
    - 验收:人物识别、五官、手指、饰品、姿势、视线和圆形裁切。
    - 不包含:程序化双色滤镜、照片后处理、Logo/文字海报、公开托管或模型调用额度。
    
    ## 用户 Profile
    
    `skill.yaml` 声明 `user-profile/v1`,可以复用用户直接保存的默认配色、头像裁切和纸张
    倾向。照片、人物身份信息、临时 Prompt、访问凭据和生成结果不会写入 Profile。
    
    ## 可信度与案例
    
    - `skill-card.yaml` / `skill-card.md`:用途、依赖、风险、输出与维度证据。
    - `cases/cases.json`:真实原图到 Riso 结果,以及四指到五指的局部修正案例。
    - `pricing-card.yaml`:免费入口的价值依据、交付边界与复评条件。
    - `references/skill-composition.md`:与职业照、通用生图和风格分析能力的边界。
    
    ## 质量门
    
    ```bash
    python3 scripts/validate_skill.py .
    ```
    
    ## 依赖
    
    - 支持图片查看与编辑的 Agent 运行时
    - `gpt-image-2`
    - Python 3.8+ 与 PyYAML,仅用于 Profile 与源码校验
    
    ## License
    
    MIT
    
  • skill-card.md 3.7 KB
    # Riso 人像 · Riso Portrait · Skill Card
    
    ## Description
    
    `lov-riso-portrait` redraws an authorized single-person photo as a recognizable Riso avatar with
    `gpt-image-2`. It locks identity and scene facts, then reviews facial structure, hands, accessories,
    objects and circular cropping before delivery.
    
    ## Owner
    
    LovStudio maintains the Skill. Product information is available at
    [lovstudio.ai](https://lovstudio.ai).
    
    ## License / Terms
    
    The source is MIT licensed. Users remain responsible for image rights and consent, provider terms,
    model usage charges, generated-output review and the final use of the portrait.
    
    ## Use Case
    
    The Skill is for people and agents who want a distinctive profile avatar without turning a real
    person into a generic AI face. It accepts one authorized portrait and a minimal brief, then outputs
    a square Riso PNG suitable for circular cropping.
    
    ## Deployment Geography
    
    It can run in any geography where the user's runtime is allowed to access `gpt-image-2` image
    editing and process the supplied photo.
    
    ## Requirements / Dependencies
    
    - Image viewing and generative raster-image editing
    - `gpt-image-2`; programmatic image filters are not an accepted substitute
    - Provider access or credentials supplied by the user's runtime
    - No sibling Skill, local post-processing library or hosted LovStudio service is required
    
    ## Known Risks and Mitigations
    
    - Models can drift identity or alter anatomy and objects. Every pass repeats identity and scene
      constraints, followed by full, face, hand and avatar-crop review.
    - A filtered photo can imitate surface texture without becoming a Riso illustration. The workflow
      requires direct redraw and rejects post-generation color separation or halftone filters.
    - Portraits can expose personal information. Inputs must be authorized, remain local unless the
      user separately approves publication, and never enter the shared Profile.
    - Generation is not deterministic. Corrections target one observable fact at a time and are not
      accepted until visually inspected.
    
    ## References
    
    - [Machine-readable card](skill-card.yaml)
    - [Primary Skill instructions](SKILL.md)
    - [Riso art direction](references/riso-art-direction.md)
    - [Quality gate](references/quality-gate.md)
    - [Skill composition record](references/skill-composition.md)
    
    ## Skill Output
    
    The normal output is a non-destructive square PNG. Optional evidence boards can compare source and
    result or show a correction sequence. Completion requires identity, anatomy, medium and circular-
    crop checks rather than visual appeal alone.
    
    ## Skill Version
    
    0.1.0
    
    ## Ethical Considerations
    
    Use only photos the user is authorized to process. Do not infer identity or sensitive traits,
    publish private portraits without separate permission, or treat the illustration as documentary
    evidence.
    
    ## LovStudio Evidence
    
    ### User Cases
    
    [`cases/cases.json`](cases/cases.json) records a three-photo source/result comparison and a real
    four-finger to five-finger correction. The user confirmed that every depicted person is authorized.
    
    ### Dimension Map
    
    The machine-readable card tracks identity fidelity, Riso medium fidelity, factual integrity and
    avatar readiness. Scores describe the current verified cases, not a guarantee for every source.
    
    ### Pricing Basis
    
    The Skill is free because it is an instruction and QA layer; users supply their own model access and
    generation usage. Hosted inference, bundled credits and rights clearance are outside the boundary.
    
    ### Distribution
    
    The intended free channels are the public GitHub repository and LovStudio Skill Publisher. Each
    channel is considered complete only after its repository, release, catalog entry, live page and
    install command are independently verified.
    
  • skill-card.yaml 5.2 KB
    schema: lovstudio/skill-card/v1
    version: "0.1.0"
    description: "Redraws an authorized single-person photo as an identity-preserving Riso avatar with gpt-image-2, then checks facial identity, anatomy, accessories and circular cropping before delivery."
    owner:
      team: "LovStudio"
      contact: "https://lovstudio.ai"
    license:
      name: MIT
      terms: "The Skill source may be used and modified under MIT. Users remain responsible for image rights, consent, provider terms, model charges and the final use of generated portraits."
      url: "LICENSE"
    use_case:
      audience: "People and agents creating distinctive profile avatars from authorized single-person photos."
      scenario: "Translate a recognizable portrait into a hand-drawn Riso medium without changing the person's identity, pose, gaze, clothing or key objects."
      tasks:
        - "Inspect the source as the edit target and identity reference."
        - "Build an identity-locked gpt-image-2 edit prompt using a limited-ink Riso art direction."
        - "Review the result at full, face-crop and circular-avatar scales."
        - "Correct one anatomy, accessory or crop fact at a time without broad regeneration."
    deployment:
      geography: global
      environments:
        - "Agent runtime with image viewing and gpt-image-2 image editing."
    requirements:
      credentials: "Provider credentials or product access required by the user's image-generation runtime."
      dependencies: []
      runtime:
        - "Image viewing"
        - "gpt-image-2 generative raster-image editing"
    risks:
      - risk: "The model may alter identity, age, pose, clothing, jewelry, fingers or held objects."
        mitigation: "Use the source as the sole identity reference, lock scene facts in every prompt, and run the face, anatomy and object quality gates after each pass."
      - risk: "A filtered photo may be mistaken for a Riso redraw."
        mitigation: "Require direct gpt-image-2 visual reorganization and reject Canvas, Sharp, CSS, color-map or post-processing substitutes."
      - risk: "Portraits may be uploaded, published or used without adequate rights or consent."
        mitigation: "Require authorized inputs, keep source and outputs local unless the user separately authorizes publication, and never persist photos in the shared Profile."
      - risk: "A square result may fail when cropped into a small circular avatar."
        mitigation: "Inspect the full hair silhouette, chin, gaze room and recognizable contrast at chat-list scale before delivery."
    references:
      - title: "Primary Skill instructions"
        path: "SKILL.md"
      - title: "Riso art direction"
        path: "references/riso-art-direction.md"
      - title: "Quality gate"
        path: "references/quality-gate.md"
      - title: "Skill composition record"
        path: "references/skill-composition.md"
    output:
      types:
        - "Riso portrait image"
        - "optional source/result comparison"
        - "optional iteration comparison"
      formats:
        - "PNG"
        - "JPEG comparison artifact"
      parameters:
        - "authorized single-person source image, avatar crop, limited-ink palette and optional creator note"
      validation:
        - "identity match at full and avatar size"
        - "face, hands, accessories, clothing and held-object fact check"
        - "limited-ink Riso medium rather than filtered photography"
        - "circular crop safety"
      description: "A non-destructive square portrait plus evidence of identity, anatomy, medium and avatar-crop review."
    ethical_considerations: "Use only photos the user is authorized to process. Do not infer identity or sensitive traits, publish private portraits without separate permission, or describe model output as a faithful documentary image."
    dimensions:
      - id: identity_fidelity
        label: "Identity fidelity"
        description: "The result remains immediately recognizable as the person in the edit target."
        evidence: "The three-photo comparison preserves distinct face, hair, gaze and clothing cues across three authorized source portraits."
        score: 0.9
      - id: medium_fidelity
        label: "Riso medium fidelity"
        description: "The model redesigns line, flat ink, halftone and paper rather than applying a photo filter."
        evidence: "The accepted gpt-image-2 result was selected against two rejected programmatic filter and over-processing stages."
        score: 0.9
      - id: factual_integrity
        label: "Factual integrity"
        description: "Visible anatomy, accessories, clothing and held objects match the source."
        evidence: "The hand-correction case records a four-finger defect and the targeted five-finger repair."
        score: 0.8
      - id: avatar_readiness
        label: "Avatar readiness"
        description: "The square output remains recognizable and safely framed after circular crop and downscaling."
        evidence: "The workflow requires full-image, face-crop, circular-crop and chat-list-size review."
        score: 0.85
    pricing:
      model: free
      currency: CNY
      list_price_cny: 0
      basis: "The Skill is a reusable instruction and QA layer; users provide their own gpt-image-2 access and usage."
      boundary: "Includes art direction and review; excludes hosted model access, generation credits, rights clearance and public publishing."
      review_trigger: "Review after 100 verified external uses, five varied public cases, or the addition of hosted generation or material support cost."
      confidence: internal
    distribution:
      paid: []
      free:
        - "github"
        - "lovstudio"
    
  • SKILL.md 8.3 KB
    ---
    name: lov-riso-portrait
    description: >
      把单人照片用 gpt-image-2 重绘成身份保真的 Riso 头像,并检查五官、手指、饰品与圆形裁切。Use when the user asks“做成 Riso 人像”“生成孔版印刷头像”or “create a Riso portrait”。
    license: MIT
    compatibility: "Portable Agent Skills format. Requires image viewing and generative raster-image editing with gpt-image-2; no local image-processing dependency is required."
    depends_on:
      - lov-branding-consistency
    metadata:
      author: LovStudio
      version: "0.2.0"
      card_standard: lovstudio/skill-card/v1
      content_class: deterministic-output
      tags:
        - riso
        - portrait
        - avatar
        - image-editing
        - identity-preservation
    ---
    
    # Riso 人像 · Riso Portrait
    
    把一张单人照片直接重绘成适合头像使用的 Riso 插画:人物仍然一眼可认,画面使用
    有限色墨、网点、纸张颗粒、粗线条和轻微套印偏移,而不是给原照片叠一层复古滤镜。
    
    ## Triggers
    
    ### Activate when
    
    - 用户说“把这张照片做成 Riso 人像”“生成孔版印刷风格头像”“做成双色网点头像”。
    - 用户要求保留人物身份、姿势和衣服特征,同时改成 Riso、Risograph 或孔版印刷视觉。
    - The user asks to “create a Riso portrait”, “turn this photo into a Risograph avatar”, or “make an identity-preserving Riso profile picture”.
    
    ### Do not activate when
    
    - 用户只想磨皮、提亮、换背景或制作职业照;使用 `lov-professional-portrait`。
    - 用户要从多张样图提炼任意视觉风格,而不是生成 Riso 人像;使用风格分析能力。
    - 用户要制作海报、信息图或带文字的传播物料;使用相应设计 Skill。
    - 当前运行时无法调用 `gpt-image-2` 做图像编辑;不得用 Canvas、Sharp、CSS、双色映射或普通滤镜伪装成同等结果。
    
    ## User Profile (cross-session)
    
    每次运行读取 `skill.yaml` 声明的 `user-profile/v1`,按当前请求、项目上下文、
    `skills.lov-riso-portrait.records`、共享 Preferences 和安全默认值解析配色与裁切偏好。
    只有用户直接声明的长期偏好才通过 `scripts/profile_store.py record --confirm` 保存;
    照片、人物身份信息、临时 Prompt、访问凭据和生成结果不得写入 Profile。
    
    ## Skill Group Composition
    
    运行前读取 `references/skill-composition.md`。本 Skill 独占“身份保真的 Riso 头像”
    这一结果;相邻能力只通过原图或完成后的 PNG 可选交接,不构成隐藏依赖。
    
    ## Implementation paths are alternatives
    
    读取 `references/implementation-options.md`。程序化滤镜、生成模型重绘和 AI 参数分析
    结合确定性渲染是三种并列方案,不是必须依次执行的三个步骤。本 Skill 的默认交付仍是
    `gpt-image-2` 直接重绘,因为它最能重新组织人物、色块、线条和网点;只有用户要求
    研究实现方式、追求参数可控或批量一致性时,才讨论其他方案,不能把双色滤镜冒充默认
    Riso 人像结果。
    
    ## Workflow (MANDATORY)
    
    ### Step 0: Resolve root and runtime
    
    1. 解析 Skill 根目录,读取 `skill.yaml`、`references/riso-art-direction.md`、
       `references/quality-gate.md`、`references/implementation-options.md` 与
       `references/skill-composition.md`。
    2. 确认运行时能查看输入图片,并能用 `gpt-image-2` 进行真正的栅格图像编辑。
    3. 若模型不可用,明确停止并说明缺少 `gpt-image-2`;不能静默替换为程序化滤镜、
       其他模型或只交付 Prompt。
    
    ### Step 1: Inspect the source as the identity reference
    
    - 把用户上传的照片视为编辑目标和人物身份的唯一事实来源。
    - 确认输入是用户选择的原始照片或经核验的权威原图,而不是既有 Riso 结果、聊天缩略
      图、被错误裁切的中间产物。多张候选图时逐张记录来源与角色,不从成品反推原图。
    - 检查脸型、五官比例、发型轮廓、神态、视线、姿势、衣服、饰品、手持物和手指。
    - 识别图片是否适合头像裁切:人脸是否清晰,发顶是否完整,视线前方是否有留白。
    - 多人照片默认不执行;先请用户明确主角或提供单人照片。
    - 只裁头部或头像用途时,先比较圆形裁切后的辨识度、视线留白、背景噪音和缩略图对比,
      再决定源图;不能只按全图“最好看”排序。
    - 不把照片、结果或人物信息公开上传,除非用户另行授权。
    
    ### Step 2: Resolve the smallest sufficient brief
    
    默认输出为 1:1 近景头像,适配圆形裁切。使用深炭黑、青绿、朱红和暖纸色;保留
    纸张颗粒、网点、粗线条、不完全均匀的墨边与轻微套印偏移。当前请求可以覆盖配色、
    景别或背景,但不能无声改变人物身份、年龄、姿势、服装或饰品。
    
    只在缺失信息会明显改变人物或头像用途时问一个问题;普通配色与裁切由安全默认值补齐。
    
    ### Step 3: Build the identity-locked edit prompt
    
    读取 `references/riso-art-direction.md`,让 Prompt 明确包含:
    
    1. `style-transfer` 用例和 `1:1 social profile avatar` 资产类型;
    2. 输入图是 edit target 和 identity reference;
    3. 人物身份、姿势、视线、服装和关键物件保持不变;
    4. Riso 的有限色墨、网点、纸张、手绘线条和轻微套印偏移;
    5. 圆形头像安全区、完整发型轮廓和视线前方留白;
    6. 禁止照片感、塑料皮肤、3D、普通动漫化、文字、Logo、水印和装饰边框。
    
    用户的补充只作为 `Creator note` 加在模板末尾,不得覆盖身份锁和事实约束。
    
    ### Step 4: Edit directly with gpt-image-2
    
    - 把原图作为 edit target 传给 `gpt-image-2`,使用高输入保真度和高质量方图输出。
    - 让模型自己重组线稿、色块、网点与纸张关系;不在生成后追加灰度分版、双色映射、
      网角、噪点或套印滤镜。
    - 非破坏性保存,建议使用“原文件名-riso-portrait-v1.png”。
    - 第一轮只生成一个主版本,先验收再决定是否需要局部修正。
    
    ### Step 5: Inspect at three scales
    
    按 `references/quality-gate.md` 检查:
    
    1. 全图:人物、姿势、衣服和画面重心是否与原图一致;
    2. 脸部近看:眼睛、鼻子、嘴唇、下颌、发际线和表情是否仍像本人;
    3. 聊天列表与圆形裁切:缩小后是否仍可识别,发顶、下巴和视线是否被边缘吃掉。
    
    另外逐项数清可见手指,检查耳饰、眼镜、项链、麦克风等高风险细节。整体氛围漂亮
    不能替代事实检查。
    
    ### Step 6: Correct one fact at a time
    
    - 身份漂移:重新强调脸型、五官比例、发型和神态,减少风格自由度。
    - 太像滤镜:要求重新组织线稿与色块,禁止保留照片式连续明暗。
    - 手指、饰品或物件错误:只修一个明确事实,并重复“其他人物特征、动作和风格不变”。
    - 常识性错误:回到源图和真实世界事实确定正确数量、结构与空间关系,再做局部修正;
      不完全信任生成模型,也不因一个局部错误重做已经验收的整张图。
    - 裁切不适合头像:只调整景别与安全区,不重做人物。
    
    每轮只解决一个问题。修正后重新执行三尺度检查,避免修好手指却改坏脸。
    
    ### Step 7: Deliver
    
    - 在运行时支持时直接展示最终 PNG,并报告保存路径。
    - 简要说明采用的配色、裁切和有意修正;同时确认身份、姿势与关键物件被保留。
    - 用户要求时另做原图/结果对照或迭代过程图,但不要把标签、水印或营销文字烘焙进头像。
    - 过程图只展示真实发生且视觉上不同的阶段;删除重复结果,不补造畸形帧,并让局部
      问题在统一尺寸的小图中清楚可见。
    
    ## Completion Criteria
    
    - 缩到头像大小仍能认出同一个人。
    - 画面是重新设计的 Riso 插画,不是带颗粒的照片或双色滤镜。
    - 五官、可见手指、饰品、衣服、姿势与视线没有事实错误。
    - 圆形裁切不会切掉发顶、下巴或视线方向。
    - 原图保持不变,最终 PNG 路径与模型边界已报告。
    
    ## Dependencies
    
    - 可查看本地或上传图片的 Agent 运行时
    - 可执行图像编辑的 `gpt-image-2`
    - 生成服务可能产生模型调用费用;Skill 本身免费
    
  • skill.yaml 1.2 KB
    schema: skill-manifest/v1
    id: lov-riso-portrait
    version: "0.2.0"
    runtime: skill-runtime/v1
    context:
      profile:
        schema: user-profile/v1
        source: shared-profile
        read:
          - user
          - brand
          - workspace
          - preferences
          - skills.lov-riso-portrait
        persist:
          enabled: true
          namespace: skills.lov-riso-portrait
          records_path: skills.lov-riso-portrait.records
          write_policy: direct-user-statement
          atomic: true
        fields:
          - path: user.language
            required: false
            question: 希望使用哪种语言交付结果?
          - path: user.timezone
            required: false
            question: 需要使用哪个时区记录生成时间?
      preferences:
        namespace: lov_riso_portrait
        fields:
          - path: skills.lov-riso-portrait.records.default_palette
            required: false
            question: 长期默认使用哪套 Riso 墨色?
          - path: skills.lov-riso-portrait.records.default_crop
            required: false
            question: 长期默认使用怎样的头像景别与裁切?
          - path: skills.lov-riso-portrait.records.default_paper
            required: false
            question: 长期默认使用怎样的纸张颜色与颗粒感?
      interaction:
        ask_missing: true
        max_questions: 1
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related