Claude Skill

lov-branding-consistency

面向公众号、网站、App、策划案、海报等真实发布场景,为读者可见文本建立受众、品牌角色、组件惯例与信息可见性门禁。Use when generating, editing, rendering, publishing, or reviewing audience-facing copy for brand and context fit.

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

Full trust report

Download lovstudio-skills-skills_branding-consistency-0b16007.zip · 41 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/branding-consistency
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

品牌审校官 · Brand Editor

Version

让公众号、网站、App、策划案、海报等场景中的每一句文案,都从真实受众与品牌角色 出发;同时隔离读者文案、无障碍文本、归属信息和内部制作说明。

本地安装

npx skills add lovstudio/branding-consistency-skill -g -y

本地真源安装:

export SKILL_SOURCE_DIR="$(pwd)"
mkdir -p "$HOME/.agents/skills"
ln -s "$SKILL_SOURCE_DIR" "$HOME/.agents/skills/lov-branding-consistency"

使用

Caption

输入:

公众号正文首图 Caption:
Piet Mondrian《Composition (No. 1) Gray-Red》与手工川官方 Logo 构成的正文首图

输出决策:正文已有完整作品说明时,首图不显示 Caption。需要归属时使用:

Piet Mondrian,《Composition (No. 1) Gray-Red》,1935。

App 微文案

输入:同步失败,请检查相关配置并重试。

输出会结合失败原因与下一步,例如:网络连接中断,Profile 尚未同步。重试。 不把统一的营销语气强塞进错误状态。

用户 Profile

skill.yaml 声明 user-profile/v1。Skill 每次读取用户、品牌、工作区、共享偏好和 skills.lov-branding-consistency.records;用户明确要求长期沿用的文案边界通过 scripts/profile_store.py 原子写回 Profile,源代码保持可移植。

原子组合

references/skill-composition.md 记录与 writing-style、humanizer、copywriting、 article creator 和发布能力的边界。所有会生成、编辑、排版或发布读者可见文本的 LovStudio Skill 都显式依赖本 Skill;纯搜索、数据、构建与部署能力不依赖。

辅助审计

python3 scripts/copy_audit.py --text '待检查文案' \
  --surface wechat --component caption

脚本只检查元话语、制作术语与组件错配;最终语境与品牌判断仍由主流程完成。

质量门

python3 scripts/copy_audit.py --self-test
python3 scripts/validate_skill.py .

生态依赖校验

references/dependent-skills.yaml 维护所有会产出受众可见文本的 LovStudio Skill。 校验 canonical source 是否都已显式依赖本 Skill:

python3 scripts/sync_dependents.py \
  --manifest references/dependent-skills.yaml \
  --search-root /path/to/lovstudio-skills \
  --search-root /path/to/oneshot-skills \
  --json

依赖

  • Python 3.8+
  • PyYAML(仅完整源校验)

License

MIT

Skill manifest

品牌审校官 · Brand Editor

把文案当作真实产品或出版物的一部分,而不是模型回答的残留物。先确定谁在什么场景 看到这句话、此刻要完成什么,再决定写什么、写多少,以及是否根本不该显示文字。

Triggers

Activate when

  • “这句 Caption 很下头,按公众号读者的习惯改好。”
  • “从品牌和目标受众角度审校这组网站文案。”
  • “给 App 的空状态、按钮和错误提示写一套专业文案。”
  • “把策划案 / 海报 / 发布说明改成目标客户真正会读的语言。”
  • “Write audience-aware copy for this product surface.”
  • “Review this UI copy against our brand voice and user context.”

Do not activate when

  • 用户只要求复现某位作者的个人文风;使用对应 writing-style 或 style-clone 能力。
  • 用户只要求检测 AI 套话、句式均匀或平台 AI 风险;使用 humanizer 或 anti-AI 审计。
  • 用户只要求营销落地页的转化结构、SEO 关键词或广告投放策略;使用 copywriting、 landing 或 SEO 能力。本 Skill 可在其输出后做语境验收。
  • 用户只要求事实研究、翻译、排版、视觉设计或正式发布,不需要改动可见文案。

User Profile

每次运行读取 skill.yaml 声明的 user-profile/v1 上下文,按当前请求、项目上下文、 本 Skill records、共享 preferences、brand/user Profile 和安全默认值解析。品牌名称、 定位、语气、禁用表达与目标受众来自 Profile 或当前 brief,不在 Skill 源中硬编码。

用户直接声明且希望跨任务沿用的文案偏好,通过:

python3 scripts/profile_store.py record \
  --skill-id lov-branding-consistency \
  --path records.<field> \
  --value '<json>' \
  --confirm

写回共享 Profile,并报告 canonical 路径。推断值、私有素材和凭据不得持久化。

Skill Group Composition

执行前读取 Skill composition。本 Skill 独占“让最终 可见文案符合具体媒介、受众与品牌角色”的验收结果;相邻能力只通过 brief、草稿、 品牌 Profile 或审计报告可选交接,不是隐藏依赖。

Cross-Skill dependency contract

当本 Skill 由另一个 Skill 的 depends_on 触发时:

  1. 只审校该 Skill 即将交付或发布的受众可见文本,不接管其事实、数据、代码或渠道职责。
  2. 引文、转录、源数据、法律文本、标识符、代码与用户原始输入默认保持逐字不变。
  3. 对标题、Caption、摘要、按钮、说明、CTA、章节名、发布描述等创作型字段执行在位验收。
  4. 修正文案直接回填目标制品;诊断和内部说明留在交付报告,不混入正文。
  5. 发布型 Skill 发现 hard failure 时先修复或停止,不能把“已调用品牌门禁”当作通过证据。

Core rule: write from the audience side

模型、设计师和运营者知道的制作事实,不自动属于读者。用户可见文案只能保留三类 信息:帮助理解、帮助行动、完成必要归属。其余制作说明留在 alt、备注、manifest、 设计稿标注或交付报告中。

例如:

  • 内部事实:这是正文首图;Logo 是官方版本;画面由原作与 Logo 合成。
  • 读者需要:作品是谁的、叫什么、何时创作;必要时补馆藏或版权信息。
  • 专业 Caption:Piet Mondrian,《Composition (No. 1) Gray-Red》,1935。
  • 更合适的选择:若正文已有“封面里的作品”完整说明,首图可以不显示 Caption。

Workflow (MANDATORY)

Step 0: Resolve context and references

  1. 读取当前请求、相关界面或文档、共享 Profile 与本 Skill records。
  2. 完整读取 Context contract。
  3. 按目标媒介读取 Scene conventions。
  4. 输出前读取 Quality gate。
  5. 输入足够时直接推进;只有缺失项会改变品牌身份、受众或行动结果时才问一个问题。

Step 1: Build a private context contract

在内部解析九项,不默认展示:

  1. surface:公众号、网站、App、策划案、海报、邮件、社交媒体或其他;
  2. component:标题、Caption、按钮、提示、Hero、正文、CTA、表单、脚注等;
  3. audience:谁会看到,他们已知道什么,最在意什么;
  4. moment:浏览、比较、决策、输入、等待、失败、完成或分享;
  5. job:这句话唯一要完成的读者任务;
  6. brand_role:个人、产品、公司、媒体、专家或平台,此刻以何种身份说话;
  7. tone:由品牌 Profile 与情境共同决定,不从通用“专业感”猜口号;
  8. constraints:字符、层级、屏幕、平台语法、法务、无障碍与可验证事实;
  9. visibility:哪些事实给读者,哪些只留在制作链。

普通任务不要把这份 contract 当作前言输出。它用于决策,不是用户制品。

Step 2: Decide whether copy should exist

先问三件事:

  1. 没有这句话,读者是否仍能正确理解或行动?
  2. 邻近标题、图片、控件或平台字段是否已经表达同一信息?
  3. 这句话是否只是在解释制作者做了什么?

三问都不支持保留时,删除优于改写。零文案是合格结果,尤其适用于装饰图片、已经 自明的正文首图、重复按钮说明和平台已展示的作者字段。

Step 3: Separate visible copy from metadata

建立可见性防火墙:

  • Visible copy:读者必须理解、决定或行动的信息。
  • Accessibility text:描述图片或控件本身,不承担营销和制作说明。
  • Attribution:作者、作品、日期、来源、版权等必要归属。
  • Production metadata:正文首图、官方 Logo、生成方式、导出规格、审批状态、 文件名、组件名与实现说明;默认不出现在读者文案。

Alt、Caption、设计标注和正文不是同一个字段,不得把一段内部描述复制到所有位置。

Step 4: Draft by component convention

  1. 先写一句只完成 job 的核心版本。
  2. 用目标组件的专业惯例决定长度、句法、标点、称谓和信息顺序。
  3. 品牌通过选词、判断、节奏与克制体现,不自报“官方”“品牌化”“专业”。
  4. 删除读者已经看得见的事实、内部术语、解释性尾巴和防御性补充。
  5. 事实、名字、日期、版本、引用、权利状态和行动后果保持准确。
  6. 用户只要局部文案时,只交付局部,不附“我做了哪些优化”。

Step 5: Review in place

把文案放回真实邻接环境再检查:上一行、下一行、图片、按钮、页面标题、平台 author 字段和移动端宽度。脱离组件单看“挺好”的句子,放回界面后可能重复、抢层级或像 设计交付说明。

可对短文案运行辅助审计:

python3 scripts/copy_audit.py --text '待检查文案' \
  --surface wechat --component caption --format json

脚本只定位可观察的元话语、制作术语与组件错配,不代替受众和品牌语义验收。

Step 6: Apply the quality gate

逐项判断:

  1. 说话者是否是目标品牌,而不是 AI、设计师或执行 Agent?
  2. 文案是否帮助目标受众完成此刻唯一任务?
  3. 是否暴露正文首图、官方 Logo、组件、生成、导出等制作链信息?
  4. 是否重复平台、界面或图片已经表达的内容?
  5. 是否把 alt、Caption、归属和正文混为一谈?
  6. 语气是否来自品牌与场景,而不是“专业、温暖、高级”等空洞形容词?
  7. 是否符合此组件的真实长度、句法、标点和行动后果?
  8. 删除后是否更好?如果是,删除。

任何一项失败都先修复,再交付。

Output Contract

  • 默认只输出可直接粘贴或写入目标位置的最终文案。
  • 审校请求先给一句结论,再给最高优先级问题;用户要求修改时直接附最终版本。
  • 存在多个合理方向时最多给三版,并明确差异来自受众或品牌策略,不堆同义句。
  • 不把内部 brief、推理步骤、制作说明、Prompt 或验收清单混入读者制品。
  • 不虚构品牌事实、用户研究、数据、评价、权利状态或产品承诺。

Dependencies

核心能力为 instruction-first,无网络、凭据或 sibling Skill 强依赖。Python 3.8+ 用于可选本地审计和 Profile 存储;完整源校验需要 PyYAML。

Files (skills)
  • cases
    • cases.json 2.4 KB
      [
        {
          "type": "case",
          "title": "公众号艺术首图不该解释自己的制作过程",
          "description": "来自 2026-08-30《一种优雅的移动端表格排版解决方案》真实发布流程。原 Caption 把作品、官方 Logo、合成方式和组件名称混在一起,读起来像设计交付说明,而不是面向公众号读者的出版文案。",
          "input": {
            "items": [
              "surface: 微信公众号文章",
              "component: 正文开篇艺术首图 Caption",
              "audience: 关注 AI 产品、工程与内容运营的读者",
              "brand role: 手工川个人品牌,以克制、专业的出版者身份说话",
              "original: Piet Mondrian《Composition (No. 1) Gray-Red》与手工川官方 Logo 构成的正文首图",
              "article already contains a full 封面里的作品 section with attribution and rights status"
            ]
          },
          "prompt": "使用 lov-branding-consistency 审校这条公众号首图 Caption。区分读者信息、作品归属、无障碍文本和内部制作说明;允许最终决定不显示 Caption。",
          "output": {
            "decision": "正文首图不显示可见 Caption;完整作品说明已经在后文承担背景与版权归属,继续显示只会重复并暴露制作过程。",
            "accessibility_alt": "Piet Mondrian《Composition (No. 1) Gray-Red》,1935",
            "optional_attribution": "Piet Mondrian,《Composition (No. 1) Gray-Red》,1935。",
            "artwork_section_caption": "Piet Mondrian,《Composition (No. 1) Gray-Red》,1935,芝加哥艺术博物馆藏。"
          },
          "assertions": {
            "must_remove_from_visible_caption": [
              "正文首图",
              "官方 Logo",
              "构成的"
            ],
            "must_preserve_when_attribution_is_needed": [
              "Piet Mondrian",
              "Composition (No. 1) Gray-Red",
              "1935"
            ],
            "must_not_claim": [
              "new artwork facts not present in the source",
              "a different museum or rights status",
              "that the logo itself is part of Mondrian's original painting"
            ]
          },
          "evidence": [
            "scripts/copy_audit.py --self-test uses the original and corrected texts as bad/good fixtures.",
            "references/scene-conventions.md states that opening art normally needs no visible Caption when attribution is already handled elsewhere.",
            "references/quality-gate.md classifies component self-reference and production metadata as hard failures."
          ]
        }
      ]
      
  • references
    • context-contract.md 2 KB
      # Context Contract
      
      文案开始前,在内部解析以下字段。不要把它们机械输出给用户。
      
      ## Required fields
      
      | Field | Question | Safe inference |
      | --- | --- | --- |
      | surface | 文案最终出现在哪里? | 从文件、组件名、URL 或请求判断 |
      | component | 标题、Caption、按钮、正文还是提示? | 从选区和邻接元素判断 |
      | audience | 谁在读?已经知道什么? | 从渠道和产品阶段判断 |
      | moment | 读者在浏览、比较、输入、失败还是完成? | 从界面状态或文章位置判断 |
      | job | 这句话唯一要完成什么? | 理解、决定、行动、归属四选一 |
      | brand_role | 谁在说话?以什么身份? | 从品牌 Profile 与发布主体判断 |
      | tone | 该品牌在此刻应多冷静、多直接? | 品牌基调 × 场景压力 |
      | constraints | 字数、屏幕、平台、法务、事实限制? | 使用目标组件的保守惯例 |
      | visibility | 哪些信息给读者,哪些只给制作链? | 默认隐藏制作过程与实现细节 |
      
      ## Ask boundary
      
      只有缺失字段会导致不同品牌主体、不同受众或不可逆行动时才问。普通 Caption、按钮、
      网页标题与文章段落可从上下文推断,直接给出最佳版本。
      
      ## Brand resolution
      
      品牌不是在每句话里复述 Logo、公司名和“官方”。按以下顺序体现:
      
      1. 事实选择与判断;
      2. 对读者的距离与称谓;
      3. 动词、句长和信息密度;
      4. 对夸张、幽默、技术术语和销售压力的容忍度;
      5. 只有完成归属或识别任务时才显式出现品牌名。
      
      ## Visibility firewall
      
      默认面向读者:结果、意义、行动、必要归属。
      
      默认留在内部:文件名、组件名、正文首图、官方 Logo、合成方式、Prompt、导出尺寸、
      实现库、审批状态、设计意图、Agent 操作、发布脚本与“本段用于……”。
      
      例外必须能回答:读者知道这项制作事实后,会更正确地理解、决定或行动吗?
      
    • dependent-skills.yaml 3.4 KB
      schema: lovstudio/branding-consistency-dependencies/v1
      dependency: lov-branding-consistency
      policy: >-
        A Skill belongs here when its normal result generates, edits, reviews, renders,
        packages, or publishes text that an end user, reader, customer, or public audience
        will see. The gate applies only to authored presentation copy and must preserve
        quotations, transcripts, source data, legal text, identifiers, code, and user-entered
        values unless the user explicitly asks to rewrite them.
      categories:
        authoring_and_editorial:
          - deep-research
          - lov-academic-translator
          - lov-anti-wechat-ai-check
          - lov-article-creator
          - lov-article-writing
          - lov-better-github-desc
          - lov-better-readme
          - lov-better-seo
          - lov-bp
          - lov-bp-deck
          - lov-bp-outline
          - lov-bp-polish
          - lov-daily-post
          - lov-dev-blog
          - lov-dev-research
          - lov-distill
          - lov-editorial-template
          - lov-fact-check
          - lov-hanzi-lens
          - lov-harness-issue
          - lov-human-writing
          - lov-kill-emoji
          - lov-legal-pages
          - lov-paper-translator
          - lov-product-onepage
          - lov-quality-gate
          - lov-review-doc
          - lov-solution-architect
          - lov-style-clone
          - lov-thesis-polish
          - lov-translation-review
          - lov-typeless-prompt
          - lov-write-professional-book
          - lov-writing-style
          - lov-xbti-creator
        visual_and_document_presentation:
          - lov-algo-viz-creator
          - lov-align-wechat-uiux
          - lov-any2deck
          - lov-any2docx
          - lov-any2pdf
          - lov-business-card
          - lov-cover-package
          - lov-describe-image
          - lov-document-illustrator
          - lov-event-curator
          - lov-event-poster
          - lov-expense-report
          - lov-gen-card
          - lov-html2pptx
          - lov-illustrate
          - lov-image-decorator
          - lov-image-translation-errata
          - lov-landing-art-direction
          - lov-landing-brand
          - lov-landing-builder
          - lov-landing-review
          - lov-landing-strategy
          - lov-mobile-adapt
          - lov-oh-my-landingpage
          - lov-professional-infographic
          - lov-proposal
          - lov-repo2docs
          - lov-rich-export
          - lov-table2image
          - lov-visual-clone
          - lovpen-cli
        product_and_media_text:
          - lov-app-generator
          - lov-media-creator
          - lov-subtitle-freedom-skill
          - lov-video-chapter
          - lov-video-chapter-design
          - lov-video-chapter-export
          - lov-video-chapter-plan
          - lov-video-chapter-render
          - lov-voice2srt
        publishing_and_distribution:
          - lov-app-release
          - lov-maintain-partners
          - lov-media-publisher
          - lov-output
          - lov-output-for-article
          - lov-output-wechat-moment
          - lov-publish-wechat-article
          - lov-publish-wechat-channels
          - lov-share-session
          - lov-skill-add-case
          - lov-skill-publisher
          - lov-wechat-article-branding-skill
          - lov-wechat-article-operator
          - lov-wechat-branding-brand-application
          - lov-wechat-branding-content-intelligence
          - lov-wechat-branding-cover-direction
          - lov-wechat-branding-quality-gate
        skill_content_lifecycle:
          - lov-skill-creator
          - lov-skill-distiller
          - lov-skill-optimizer
          - lov-skill-pricing
          - lov-skill-visualizer
      excluded_categories:
        - pure search, retrieval, crawling, or data transport
        - build, deployment, release infrastructure, and runtime diagnostics without authored public copy
        - image-only generation or deterministic Logo composition without visible prose
        - code refactoring, package installation, authentication, indexing, and storage
      
    • quality-gate.md 1.9 KB
      # Quality Gate
      
      ## Hard failures
      
      以下任一出现且不服务读者任务时,必须删除或重写:
      
      - 组件自指:正文首图、封面图、配图、按钮文案、此处文案、当前页面;
      - 制作自指:官方 Logo、品牌色、由……构成、AI 生成、导出、渲染、组件、Prompt;
      - Agent 自指:下面是、我已经、我会、为你生成、根据你的要求;
      - 无信息自证:专业、高级、官方、用心打造、精心设计;
      - 邻接重复:标题、图片、label、author 字段或 CTA 已表达同一内容;
      - 错误角色:设计师对甲方解释、Agent 对用户汇报,却出现在面向读者的成品里;
      - 错误字段:把 alt 当 Caption、把设计备注当正文、把来源说明塞进按钮。
      
      ## Seven-pass review
      
      1. **Speaker**:读起来是谁在说?是否等于品牌在此刻的真实角色?
      2. **Audience**:读者知道这些背景吗?是否使用了他们的词汇与信息密度?
      3. **Job**:每句话是否只完成一个理解、决定、行动或归属任务?
      4. **Visibility**:是否把制作链信息误当成读者内容?
      5. **Convention**:长度、顺序、标点、称谓是否符合真实组件?
      6. **Neighbourhood**:与周围标题、图片、控件和平台字段是否重复或抢层级?
      7. **Brand**:删掉品牌名后,判断、节奏与克制是否仍体现同一品牌?
      
      ## Caption test
      
      Caption 只有三种正当任务:
      
      1. 识别画面中不明显的对象;
      2. 提供理解画面所需的背景;
      3. 完成作者、作品、时间、来源或版权归属。
      
      若只是在说“这是正文首图”“使用官方 Logo”“由这些元素构成”,删除 Caption。
      
      ## Deletion test
      
      删除后:
      
      - 理解不受损;
      - 行动不受损;
      - 必要归属仍在;
      - 页面层级更安静;
      
      则删除是最终版本,不必为了交付“有一条文案”而补写。
      
    • scene-conventions.md 2.9 KB
      # Scene Conventions
      
      ## 微信公众号与编辑出版
      
      - 标题承诺读者将获得的判断或信息,不解释“本文将介绍”。
      - 导语建立问题、现场或价值,不复述标题和摘要。
      - 正文首图通常不需要可见 Caption;需要归属时只写作品、作者、年代、馆藏或来源。
      - Alt 描述图像内容,Caption 提供读者所需的补充,作品说明承载背景;三者不复制。
      - 不出现“正文首图”“配图”“官方 Logo”“由……构成”“本图展示”等制作话语。
      - 公众号已有作者字段时,正文不再放“文 / 作者”。
      
      ## 网站与 Landing Page
      
      - Hero 先说用户结果,再用证据或限制建立可信度,CTA 使用具体动作。
      - 导航、标题、说明和按钮各承担一层,不在四处重复产品名与同一承诺。
      - 不把 SEO 关键词、组件名、设计策略或“品牌化”写进正文。
      - 品牌感来自立场、用词和证明,不来自“领先、专业、高端、官方”的自我宣布。
      
      ## App 与产品微文案
      
      - 按钮写动作;状态写发生了什么;错误写原因、影响与可行下一步。
      - 高压或失败状态减少品牌腔和幽默,不用营销话术遮挡问题。
      - Tooltip 只补充控件无法自明的信息,不复述 label。
      - 空状态说明用户为什么会看到空、如何开始;不把数据库、接口或组件实现暴露出来。
      - 危险操作明确对象与后果,不能靠模糊的“确定吗”。
      
      ## 策划案、提案与报告
      
      - 面向决策者写结论、依据、影响、成本、风险和下一步。
      - 章节标题携带判断,不写“背景介绍”“方案说明”等空标签。
      - 区分事实、假设与建议,不把内部 brainstorming 当作最终主张。
      - 不用品牌口号代替商业证据,不用“赋能、生态、闭环”代替机制。
      
      ## 海报与活动物料
      
      - 一张海报只有一个主信息层级:活动是什么、为何值得、何时何地、如何行动。
      - 标题短而可识别;副标题补差异;时间地点与 CTA 直接可扫描。
      - 不描述“主视觉”“官方 Logo”“海报设计”“由 AI 生成”等制作事实。
      - 品牌签名服从活动信息,不抢夺主标题。
      
      ## 社交媒体与通知
      
      - 首句提供事实、冲突或结果,不以“重磅”“终于来了”作为默认开场。
      - 根据读者与关系选择第一人称、机构口吻或产品口吻,不混用。
      - 通知优先时间、影响与动作;宣传帖优先新信息与证据。
      - 标签、平台提示、转发语和内部发布状态不进入正文,除非用户确实需要。
      
      ## Email 与服务沟通
      
      - 主题行说明事项和所需动作。
      - 正文按背景、请求、截止时间、下一步组织;减少礼貌填充。
      - 客服回复先承认具体问题,再说明可验证进展,不用品牌宏大叙事。
      - 自动邮件清楚标识触发原因、影响与退订/管理入口。
      
    • 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 3.2 KB
      # Skill Group Composition
      
      ## Nearby Skills Inspected
      
      - `lov-writing-style`:按已校准的个人文风完成长文、改写或诊断。它拥有“像谁写”和
        题材适配,不拥有任意界面组件的受众、品牌角色与可见性边界。
      - `humanizer-zh`:删除 AI 套话、公式结构与机械节奏。它能改善自然度,但不会决定
        Caption 是否应存在、按钮在失败状态该承担什么任务。
      - `lov-anti-wechat-ai-check`:面向公众号平台风险检测模板短语和结构。它的验收目标
        是 AI 痕迹,不是跨媒介语境适配。
      - `copywriting`:面向营销页面的价值主张、异议处理、证明与转化。它是网站营销场景
        的可选上游,不适合 App 状态、出版 Caption 或策划案决策语言的统一验收。
      - `lov-article-creator`:建立公众号事实账本、文章结构与视觉包。它可交接文章 Markdown
        与组件清单;本 Skill 负责其中标题、Caption、CTA、品牌尾注等可见文案的在位审校。
      - `lov-output-for-article` 与 `lov-publish-wechat-article`:分别负责文件落盘和公众号草稿
        写入,不拥有文案判断。文案规则变化后需要新的外部发布授权。
      
      ## Atomic Handoffs
      
      1. 可选上游:writing-style、copywriting 或 article creator 输出草稿、组件清单与品牌
         Profile。上游验收事实、结构或转化策略;本 Skill 从用户可见文本开始。
      2. 核心:`lov-branding-consistency` 接收原文、目标 surface/component、受众与品牌上下文,
         输出可直接使用的文案或删除决策;验收包括语境、可见性、品牌、组件惯例与事实。
      3. 可选下游:humanizer 或 anti-AI 能力接收已经通过语境验收的长文,输出自然度或
         平台风险报告;不得重新加入内部元话语或改变品牌角色。
      4. 可选下游:文件输出、设计、App 实现或公众号发布能力消费最终文本。它们只负责
         落位与渠道状态,修改文案后必须重新经过本 Skill 的在位验收。
      
      ## Overlap Decisions
      
      - 与 writing-style 的共同点是调整语言;前者追求稳定个人声音,本 Skill 追求具体场景
        的受众与品牌适配,两者可串联但不能互相替代。
      - 与 humanizer、anti-AI 的共同点是删除模型痕迹;本 Skill 处理更上游的叙述位置和
        信息边界,即使一句话没有套话,也可能因为暴露“正文首图”“官方 Logo”而失败。
      - 与 copywriting 的共同点是考虑受众;copywriting 拥有营销转化,本 Skill 覆盖营销
        之外的出版、产品、方案与视觉组件,并负责跨场景一致的可见性门禁。
      - 不扩展任何现有 Skill,因为这些能力的 routing 与最终验收均不同。
      
      ## Composition Decision
      
      这是 Single Skill。生成、改写、审校和删除决策共享同一份 context contract、可见性
      防火墙与质量门,构成一个用户结果;`copy_audit.py` 只是辅助诊断,不拥有独立交付。
      所有 sibling Skill 均通过文本、brief、Profile 或报告交接。凡会输出读者可见文本的
      LovStudio Skill,必须在其 frontmatter 的 `depends_on` 中显式声明
      `lov-branding-consistency`;依赖清单由 `references/dependent-skills.yaml` 管理并验证。
      
    • 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
    • copy_audit.py 5 KB
      #!/usr/bin/env python3
      """Conservative detector for production-language leaks in visible copy."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      
      PATTERNS = [
          (
              "component_self_reference",
              re.compile(r"正文首图|封面图|配图|按钮文案|此处文案|当前页面|本图(?:展示|说明)"),
              "可见文案正在解释它属于哪个组件;改为读者需要的内容,或删除。",
          ),
          (
              "production_metadata",
              re.compile(r"官方\s*[Ll]ogo|品牌色|(?:由.{0,24})?构成(?:的)?|AI\s*生成|渲染(?:结果|输出)?|导出(?:图片|结果)?|Prompt"),
              "检测到制作链或设计交付信息;默认移出读者文案。",
          ),
          (
              "agent_meta",
              re.compile(r"^(?:下面是|以下是|我会|我已经|已为你|根据你的要求|为你生成)"),
              "检测到 Agent 汇报口吻;只交付目标场景需要的成品。",
          ),
          (
              "empty_self_praise",
              re.compile(r"专业(?:级)?|高端|高级感|精心设计|用心打造|重磅来袭"),
              "检测到缺少证据的自我评价;用事实、结果或具体差异替代。",
          ),
      ]
      
      
      def audit(text: str, surface: str, component: str) -> dict:
          findings = []
          for finding_id, pattern, suggestion in PATTERNS:
              for match in pattern.finditer(text):
                  findings.append(
                      {
                          "id": finding_id,
                          "match": match.group(0),
                          "start": match.start(),
                          "end": match.end(),
                          "suggestion": suggestion,
                      }
                  )
      
          if component == "caption" and len(text.strip()) > 60:
              findings.append(
                  {
                      "id": "caption_length",
                      "match": str(len(text.strip())),
                      "start": 0,
                      "end": len(text),
                      "suggestion": "Caption 过长;只保留识别、理解或必要归属。",
                  }
              )
      
          if component in {"button", "cta"} and re.search(r"[。!?!?]$", text.strip()):
              findings.append(
                  {
                      "id": "control_sentence_punctuation",
                      "match": text.strip()[-1:],
                      "start": max(0, len(text.strip()) - 1),
                      "end": len(text.strip()),
                      "suggestion": "按钮或 CTA 默认使用动作短语,不写成完整说明句。",
                  }
              )
      
          return {
              "ok": not findings,
              "surface": surface,
              "component": component,
              "chars": len(text.strip()),
              "findings": findings,
              "limitations": [
                  "This audit detects observable wording only.",
                  "Audience fit, brand voice, truth, and in-place hierarchy require semantic review.",
              ],
          }
      
      
      def self_test() -> int:
          bad = "Piet Mondrian《Composition (No. 1) Gray-Red》与手工川官方 Logo 构成的正文首图"
          good = "Piet Mondrian,《Composition (No. 1) Gray-Red》,1935。"
          bad_result = audit(bad, "wechat", "caption")
          good_result = audit(good, "wechat", "caption")
          expected = {"production_metadata", "component_self_reference"}
          actual = {item["id"] for item in bad_result["findings"]}
          if not expected.issubset(actual):
              raise AssertionError(f"missing findings: {sorted(expected - actual)}")
          if not good_result["ok"]:
              raise AssertionError(f"good example failed: {good_result['findings']}")
          production_matches = {
              item["match"] for item in bad_result["findings"] if item["id"] == "production_metadata"
          }
          if not {"官方 Logo", "构成的"}.issubset(production_matches):
              raise AssertionError(f"missing production matches: {sorted(production_matches)}")
          print("SELF-TEST PASSED")
          return 0
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description=__doc__)
          source = parser.add_mutually_exclusive_group()
          source.add_argument("--text")
          source.add_argument("--input", type=Path)
          parser.add_argument("--surface", default="unspecified")
          parser.add_argument("--component", default="body")
          parser.add_argument("--format", choices=("text", "json"), default="text")
          parser.add_argument("--self-test", action="store_true")
          args = parser.parse_args()
      
          if args.self_test:
              return self_test()
          if args.text is None and args.input is None:
              parser.error("provide --text, --input, or --self-test")
      
          text = args.text if args.text is not None else args.input.read_text(encoding="utf-8")
          result = audit(text, args.surface, args.component)
          if args.format == "json":
              print(json.dumps(result, ensure_ascii=False, indent=2))
          elif result["ok"]:
              print("PASS: no observable production-language leak found")
          else:
              print(f"FAIL: {len(result['findings'])} finding(s)")
              for item in result["findings"]:
                  print(f"- {item['id']}: {item['match']} — {item['suggestion']}")
          return 0 if result["ok"] else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • 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())
      
    • sync_dependents.py 6 KB
      #!/usr/bin/env python3
      """Declare and verify the branding consistency dependency across canonical Skills."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import tempfile
      from pathlib import Path
      from typing import Any
      
      import yaml
      
      
      DEPENDENCY = "lov-branding-consistency"
      IGNORED_PARTS = {
          ".backups",
          ".git",
          ".worktrees",
          "node_modules",
          "output",
          "dist",
          "build",
      }
      
      
      def parse_frontmatter(path: Path) -> tuple[dict[str, Any], list[str], int]:
          lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
          if not lines or lines[0].strip() != "---":
              raise ValueError("missing YAML frontmatter")
          end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
          if end is None:
              raise ValueError("unterminated YAML frontmatter")
          data = yaml.safe_load("".join(lines[1:end])) or {}
          if not isinstance(data, dict):
              raise ValueError("frontmatter is not a mapping")
          return data, lines, end
      
      
      def manifest_names(path: Path) -> list[str]:
          data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
          names: list[str] = []
          for values in (data.get("categories") or {}).values():
              names.extend(values or [])
          duplicates = sorted({name for name in names if names.count(name) > 1})
          if duplicates:
              raise ValueError(f"duplicate manifest names: {duplicates}")
          if DEPENDENCY in names:
              raise ValueError("the dependency cannot depend on itself")
          return names
      
      
      def discover(roots: list[Path], wanted: set[str]) -> dict[str, list[Path]]:
          found = {name: [] for name in wanted}
          seen: set[Path] = set()
          for root in roots:
              if not root.exists():
                  continue
              for path in root.rglob("SKILL.md"):
                  if any(part in IGNORED_PARTS for part in path.relative_to(root).parts):
                      continue
                  resolved = path.resolve()
                  if resolved in seen:
                      continue
                  seen.add(resolved)
                  try:
                      data, _, _ = parse_frontmatter(path)
                  except (OSError, ValueError, yaml.YAMLError):
                      continue
                  name = data.get("name")
                  if name in wanted:
                      found[name].append(path)
          return found
      
      
      def dependency_values(data: dict[str, Any]) -> list[str]:
          value = data.get("depends_on", [])
          if value is None:
              return []
          if isinstance(value, str):
              return [value]
          if isinstance(value, list):
              return [str(item) for item in value]
          raise ValueError("depends_on must be a string or list")
      
      
      def add_dependency(path: Path) -> bool:
          data, lines, end = parse_frontmatter(path)
          if DEPENDENCY in dependency_values(data):
              return False
      
          start = next((i for i in range(1, end) if re.match(r"^depends_on\s*:", lines[i])), None)
          if start is None:
              insertion = next((i for i in range(1, end) if re.match(r"^metadata\s*:", lines[i])), end)
              lines[insertion:insertion] = [f"depends_on:\n", f"  - {DEPENDENCY}\n"]
          else:
              inline = lines[start].split(":", 1)[1].strip()
              if inline:
                  current = yaml.safe_load(inline)
                  values = [current] if isinstance(current, str) else list(current or [])
                  replacement = ["depends_on:\n"] + [f"  - {item}\n" for item in values + [DEPENDENCY]]
                  lines[start : start + 1] = replacement
              else:
                  stop = start + 1
                  while stop < end and (lines[stop].startswith((" ", "\t")) or not lines[stop].strip()):
                      stop += 1
                  lines[stop:stop] = [f"  - {DEPENDENCY}\n"]
      
          content = "".join(lines)
          original_mode = path.stat().st_mode & 0o777
          fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
          try:
              os.chmod(temp_name, original_mode)
              with os.fdopen(fd, "w", encoding="utf-8") as handle:
                  handle.write(content)
                  handle.flush()
                  os.fsync(handle.fileno())
              os.replace(temp_name, path)
          finally:
              if os.path.exists(temp_name):
                  os.unlink(temp_name)
          return True
      
      
      def main() -> int:
          parser = argparse.ArgumentParser()
          parser.add_argument("--manifest", type=Path, required=True)
          parser.add_argument("--search-root", action="append", type=Path, required=True)
          parser.add_argument("--apply", action="store_true")
          parser.add_argument(
              "--allow-missing",
              action="store_true",
              help="Validate only discovered entries, for partial installation roots",
          )
          parser.add_argument("--json", action="store_true")
          args = parser.parse_args()
      
          names = manifest_names(args.manifest)
          found = discover(args.search_root, set(names))
          missing = sorted(name for name, paths in found.items() if not paths)
          changed: list[str] = []
          invalid: list[dict[str, str]] = []
      
          for name in names:
              for path in found[name]:
                  try:
                      data, _, _ = parse_frontmatter(path)
                      if DEPENDENCY not in dependency_values(data):
                          if args.apply:
                              add_dependency(path)
                          changed.append(str(path))
                  except (OSError, ValueError, yaml.YAMLError) as exc:
                      invalid.append({"path": str(path), "error": str(exc)})
      
          result = {
              "dependency": DEPENDENCY,
              "declared_skill_names": len(names),
              "discovered_skill_files": sum(len(paths) for paths in found.values()),
              "missing_skill_names": missing,
              "missing_allowed": args.allow_missing,
              "invalid": invalid,
              "mode": "apply" if args.apply else "check",
              "changed_or_missing_dependency": changed,
              "ok": (args.allow_missing or not missing)
              and not invalid
              and (args.apply or not changed),
          }
          if args.json:
              print(json.dumps(result, ensure_ascii=False, indent=2))
          else:
              print(result)
          return 0 if result["ok"] else 1
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • validate_skill.py 24.7 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"
      
      
      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)})"
              )
      
          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")
              dependencies = metadata.get("dependencies", [])
              if not isinstance(dependencies, list):
                  errors.append(f"{path}: metadata.dependencies must be a list")
              if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD:
                  errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}")
      
          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())
      
  • tests
    • trigger-evals.json 933 B
      {
        "schema": "lovstudio/trigger-evals/v1",
        "skill": "lov-branding-consistency",
        "cases": [
          {
            "query": "这句 Caption 很像设计交付说明,请按公众号读者与品牌角度改好",
            "expected": "activate",
            "reason": "明确要求对公众号 Caption 做受众、品牌与组件语境审校。"
          },
          {
            "query": "Review this UI copy against our brand voice and user context",
            "expected": "activate",
            "reason": "明确要求 UI 文案的品牌与用户语境验收。"
          },
          {
            "query": "把这篇中文文章翻译成英文,保持原意",
            "expected": "do_not_activate",
            "reason": "纯翻译不要求受众、品牌或组件文案设计。"
          },
          {
            "query": "发布这份已经定稿的 Markdown 到公众号草稿箱",
            "expected": "do_not_activate",
            "reason": "只要求渠道写入,不要求改变可见文案。"
          }
        ]
      }
      
  • .gitignore 84 B · in bundle
  • CHANGELOG.md 634 B
    # Changelog
    
    ## 0.2.0
    
    - 更名为 `lov-branding-consistency`,明确其为所有读者可见文本的横切品牌门禁。
    - 新增 LovStudio 文本输出 Skill 的显式依赖清单与自动校验。
    - 将 `compatibility` 提升为标准顶层字段,并补充英文触发描述。
    
    ## 0.1.0
    
    - 创建跨公众号、网站、App、策划案、海报、社交媒体与邮件的语境文案工作流。
    - 引入九字段 context contract、可见性防火墙、场景惯例和七遍质量门。
    - 新增 Caption 删除测试与 `copy_audit.py` 辅助审计。
    - 以公众号艺术首图的真实 Caption 缺陷建立首个案例。
    
  • LICENSE 1 KB · in bundle
  • pricing-card.yaml 912 B
    schema: lovstudio/pricing-card/v1
    version: "0.2.0"
    model: free
    currency: CNY
    list_price_cny: 0
    value_anchor: "把散落在公众号、网站、App、策划案与海报里的文案判断,统一为受众、品牌角色、组件惯例、可见性防火墙和删除测试。"
    basis: "当前交付是 instruction-first 的本地基础能力,无云端推理、API、数据订阅或运维成本;已有一个真实 Caption 案例和可复测辅助审计,因此保持免费本地使用。"
    boundary: "包含跨场景生成、改写、在位审校、删除决策、Profile 记录与本地辅助审计;不包含事实研究、用户调研、视觉设计、代码实现、A/B 测试、渠道写入或正式发布。"
    review_trigger: "积累至少 20 个跨场景真实验收案例、增加云端品牌知识库、团队协作或自动化批量审校后重新评估价格与支持边界。"
    confidence: case-backed
    
  • README.md 2.5 KB
    # 品牌审校官 · Brand Editor
    
    ![Version](https://img.shields.io/badge/version-0.2.0-CC785C)
    
    让公众号、网站、App、策划案、海报等场景中的每一句文案,都从真实受众与品牌角色
    出发;同时隔离读者文案、无障碍文本、归属信息和内部制作说明。
    
    ## 本地安装
    
        npx skills add lovstudio/branding-consistency-skill -g -y
    
    本地真源安装:
    
        export SKILL_SOURCE_DIR="$(pwd)"
        mkdir -p "$HOME/.agents/skills"
        ln -s "$SKILL_SOURCE_DIR" "$HOME/.agents/skills/lov-branding-consistency"
    
    ## 使用
    
    ### Caption
    
    输入:
    
        公众号正文首图 Caption:
        Piet Mondrian《Composition (No. 1) Gray-Red》与手工川官方 Logo 构成的正文首图
    
    输出决策:正文已有完整作品说明时,首图不显示 Caption。需要归属时使用:
    
        Piet Mondrian,《Composition (No. 1) Gray-Red》,1935。
    
    ### App 微文案
    
    输入:`同步失败,请检查相关配置并重试。`
    
    输出会结合失败原因与下一步,例如:`网络连接中断,Profile 尚未同步。重试`。
    不把统一的营销语气强塞进错误状态。
    
    ## 用户 Profile
    
    `skill.yaml` 声明 `user-profile/v1`。Skill 每次读取用户、品牌、工作区、共享偏好和
    `skills.lov-branding-consistency.records`;用户明确要求长期沿用的文案边界通过
    `scripts/profile_store.py` 原子写回 Profile,源代码保持可移植。
    
    ## 原子组合
    
    `references/skill-composition.md` 记录与 writing-style、humanizer、copywriting、
    article creator 和发布能力的边界。所有会生成、编辑、排版或发布读者可见文本的
    LovStudio Skill 都显式依赖本 Skill;纯搜索、数据、构建与部署能力不依赖。
    
    ## 辅助审计
    
        python3 scripts/copy_audit.py --text '待检查文案' \
          --surface wechat --component caption
    
    脚本只检查元话语、制作术语与组件错配;最终语境与品牌判断仍由主流程完成。
    
    ## 质量门
    
        python3 scripts/copy_audit.py --self-test
        python3 scripts/validate_skill.py .
    
    ## 生态依赖校验
    
    `references/dependent-skills.yaml` 维护所有会产出受众可见文本的 LovStudio Skill。
    校验 canonical source 是否都已显式依赖本 Skill:
    
        python3 scripts/sync_dependents.py \
          --manifest references/dependent-skills.yaml \
          --search-root /path/to/lovstudio-skills \
          --search-root /path/to/oneshot-skills \
          --json
    
    ## 依赖
    
    - Python 3.8+
    - PyYAML(仅完整源校验)
    
    ## License
    
    MIT
    
  • skill-card.md 2.8 KB
    # 品牌审校官 · Brand Editor · Skill Card
    
    ## Description
    
    `lov-branding-consistency` 为公众号、网站、App、策划案、海报、社交媒体与邮件建立统一的
    受众、品牌角色、组件惯例和信息可见性门禁。它既能生成或改写文案,也能判断一句话
    是否根本不该显示。
    
    ## Owner
    
    LovStudio;本地 canonical Skill source 维护。
    
    ## License / Terms
    
    MIT。Skill 指令和本地脚本可按许可证使用;输入、品牌资料与输出归用户所有。
    
    ## Use Case
    
    面向创作者、产品经理、设计师、运营者和开发团队。输入一句文案、完整草稿、界面
    选区或组件清单;输出可直接落位的版本、删除决策或聚焦审校。
    
    ## Deployment Geography
    
    全球;在 Agent Skills 兼容运行时本地执行。
    
    ## Requirements / Dependencies
    
    核心能力为 instruction-first,不需要凭据或外部 Skill。Python 3.8+ 用于 Profile 和
    可选审计;PyYAML 用于完整源校验。
    
    ## Known Risks and Mitigations
    
    - 品牌资料不足时,避免用“高级、专业、温暖”补造品牌性格;从当前场景作保守推断。
    - 可见性防火墙阻止正文首图、官方 Logo、组件和生成方式进入读者文案。
    - 删除测试保护理解、行动和必要归属,不把“越短越好”当作目标。
    - 最终验收必须在邻近标题、图片、控件和平台字段中进行,不能只看孤立句子。
    
    ## References
    
    - [Machine-readable card](skill-card.yaml)
    - [Primary Skill instructions](SKILL.md)
    - [Context contract](references/context-contract.md)
    - [Scene conventions](references/scene-conventions.md)
    - [Quality gate](references/quality-gate.md)
    - [Skill composition](references/skill-composition.md)
    
    ## Skill Output
    
    UTF-8 文案、Markdown 局部成品、删除决策或 JSON 辅助审计。输入参数包括 surface、
    component、audience、moment、job、brand role、tone、constraints 与 visibility。
    
    ## Skill Version
    
    0.2.0
    
    ## Ethical Considerations
    
    不虚构受众研究、评价、产品承诺、权利状态、紧迫性、法律保证或用户同意;内部语境
    与制作信息不进入公开文案,除非法律、版权或透明度要求必须披露。
    
    ## LovStudio Evidence
    
    ### User Cases
    
    [`cases/cases.json`](cases/cases.json) 记录 2026-08-30 公众号艺术首图 Caption 的真实
    Input → Prompt → Output:最优结果不是润色制作说明,而是删除可见 Caption,并把
    作者、作品、年代留给必要归属位置。
    
    ### Dimension Map
    
    机器卡记录语境适配、可见性完整性、品牌一致性、组件惯例、事实与克制五个维度;
    不制造无证据分数。
    
    ### Pricing Basis
    
    见 [`pricing-card.yaml`](pricing-card.yaml)。当前为免费本地基础能力。
    
    ### Distribution
    
    本地安装已验证;GitHub、LovStudio、WorkBuddy 与 SkillPay 均未发布或上传。
    
  • skill-card.yaml 6.9 KB
    schema: lovstudio/skill-card/v1
    version: "0.2.0"
    description: "从真实发布场景出发,把受众、品牌角色、组件惯例和信息可见性转成可直接使用的公众号、网站、App、策划案与海报文案,并识别应当删除的多余文字。"
    owner:
      team: "LovStudio"
      contact: "Local Skill source"
    license:
      name: MIT
      terms: "Skill 指令和本地脚本按 MIT 使用;输入、品牌资料与输出归用户所有。"
      url: "LICENSE"
    use_case:
      audience: "需要让公开内容、产品界面与商业文档稳定符合目标受众和品牌审美的创作者、产品经理、设计师、运营者与开发团队。"
      scenario: "输入一句文案、完整草稿、界面选区或组件清单,结合目标场景、受众和品牌 Profile,输出可直接落位的版本、删除决策或聚焦审校。"
      tasks:
        - "建立 surface、component、audience、moment、job 与 brand role 的私有 context contract"
        - "隔离读者文案、无障碍文本、归属信息与内部制作说明"
        - "按公众号、网站、App、策划案、海报、社交媒体和邮件惯例生成或改写"
        - "在真实邻接环境检查重复、抢层级、错误角色与错误字段"
        - "以删除测试阻止为了交付文字而制造无信息 Caption 或说明"
    deployment:
      geography: global
      environments:
        - "Any Agent Skills compatible runtime"
    requirements:
      credentials: "none"
      dependencies: []
      runtime:
        - "A language model capable of audience and brand reasoning"
        - "Python 3.8+ for optional audit and Profile storage"
        - "PyYAML for full source validation"
    risks:
      - risk: "只有泛化品牌形容词,没有真实品牌 Profile,导致所有品牌听起来一样。"
        mitigation: "优先读取当前请求和 Profile;品牌通过事实选择、判断、节奏与克制体现,不靠自报高级或专业。"
      - risk: "把内部制作说明、组件名、Logo 变体或 Agent 汇报写进读者文案。"
        mitigation: "Visibility firewall 与 hard-failure gate 默认隔离 production metadata;copy_audit.py 提供可复测诊断。"
      - risk: "为了简短而删除必要归属、风险、行动后果或无障碍信息。"
        mitigation: "删除测试同时保护理解、行动与必要归属;Alt、Caption、正文和设计标注分别验收。"
      - risk: "脱离真实界面审校,局部文案单看顺口,落位后重复或抢层级。"
        mitigation: "Step 5 强制读取邻近标题、图片、控件、平台字段与移动端约束后再通过质量门。"
    references:
      - title: "Primary Skill instructions"
        path: "SKILL.md"
      - title: "Context contract"
        path: "references/context-contract.md"
      - title: "Scene conventions"
        path: "references/scene-conventions.md"
      - title: "Quality gate"
        path: "references/quality-gate.md"
      - title: "Skill composition"
        path: "references/skill-composition.md"
    output:
      types:
        - "audience-aware final copy"
        - "brand-aware rewrite"
        - "in-place copy review"
        - "delete or no-copy decision"
        - "optional observable audit report"
      formats:
        - "UTF-8 plain text"
        - "UTF-8 Markdown"
        - "JSON audit report"
      parameters:
        - "surface, component, audience, moment, job, brand role, tone, constraints, visibility"
      validation:
        - "Visible copy speaks from the intended brand role to the intended audience"
        - "Production metadata and Agent commentary do not leak into reader-facing copy"
        - "Component length, syntax, hierarchy, attribution, and action consequences match the target surface"
        - "Adjacent text and platform fields do not repeat the same information"
        - "Facts, names, dates, versions, rights, quotes, and product promises remain supported"
      description: "A directly usable text artifact or deletion decision that fits the target audience, brand role, surface, component, and information hierarchy."
    ethical_considerations: "Do not fabricate audience research, testimonials, product claims, rights, brand facts, urgency, legal assurances, or user consent. Keep private context and production metadata out of public copy unless disclosure is required."
    dimensions:
      - id: context_fit
        label: "语境适配"
        description: "文案符合目标 surface、component、audience、moment 与唯一读者任务。"
        evidence: "cases/cases.json 的公众号 Caption 案例将制作汇报改为无 Caption 决策和必要归属文本;references/scene-conventions.md 覆盖七类场景。"
        score: null
      - id: visibility_integrity
        label: "可见性完整性"
        description: "读者文案、Alt、归属与 production metadata 保持明确边界。"
        evidence: "真实案例识别‘官方 Logo’‘正文首图’‘构成’泄漏;scripts/copy_audit.py self-test 可复测 bad/good 两端。"
        score: null
      - id: brand_alignment
        label: "品牌一致性"
        description: "品牌通过事实选择、叙述位置、节奏与克制体现,不靠无证据自我评价。"
        evidence: "SKILL.md Step 1、Step 4 与 quality gate 的 Brand pass 共同定义品牌角色和表达门禁。"
        score: null
      - id: component_convention
        label: "组件惯例"
        description: "标题、Caption、按钮、错误、CTA、提案标题等遵守各自的信息职责与长度。"
        evidence: "references/scene-conventions.md 与 quality-gate.md 分别定义媒介惯例和 Caption/delete test。"
        score: null
      - id: truth_and_restraint
        label: "事实与克制"
        description: "不虚构品牌、受众研究与承诺,且允许删除比改写更专业。"
        evidence: "真实案例保留作者、作品和年代,删除制作过程;Output Contract 与 ethical considerations 禁止无证据扩写。"
        score: null
    pricing:
      model: free
      currency: CNY
      list_price_cny: 0
      basis: "当前为 instruction-first 的本地基础能力,无云端推理、API 或运维成本;首个真实案例已经形成可复测的语境与可见性证据。"
      boundary: "包含跨场景文案生成、改写、在位审校、删除决策、Profile 与本地辅助审计;不包含事实研究、用户调研、视觉设计、代码实现、A/B 测试或远程发布。"
      review_trigger: "积累至少 20 个跨场景真实验收案例、增加云端品牌知识库或提供团队级自动审校服务时复评。"
      confidence: case-backed
    distribution:
      paid:
        - channel: workbuddy
          state: not_prepared
          note: "未制作付费包,未上传,未审核,未上线。"
        - channel: skillpay
          state: not_prepared
          note: "未创建商品,未上传,未审核,未上线。"
      free:
        - channel: local
          state: installed_verified
          note: "本地真源、共享安装入口、Profile、案例与辅助审计已验证。"
        - channel: github
          state: not_published
          note: "本次未创建远程仓库或 Release。"
        - channel: lovstudio
          state: not_published
          note: "本次未执行目录上架或公开页面回读。"
    
  • SKILL.md 9.2 KB
    ---
    name: lov-branding-consistency
    description: >
      面向公众号、网站、App、策划案、海报等真实发布场景,为读者可见文本建立受众、品牌角色、组件惯例与信息可见性门禁。Use when generating, editing, rendering, publishing, or reviewing audience-facing copy for brand and context fit.
    license: MIT
    compatibility: "Portable Agent Skills format. Core workflow is instruction-first; Python 3.8+ supports optional copy audits, Profile storage, and dependency validation."
    metadata:
      author: LovStudio
      version: "0.2.0"
      card_standard: lovstudio/skill-card/v1
      tags:
        - branding-consistency
        - brand-voice
        - audience
        - editorial-review
        - microcopy
      dependencies: []
    ---
    
    # 品牌审校官 · Brand Editor
    
    把文案当作真实产品或出版物的一部分,而不是模型回答的残留物。先确定谁在什么场景
    看到这句话、此刻要完成什么,再决定写什么、写多少,以及是否根本不该显示文字。
    
    ## Triggers
    
    ### Activate when
    
    - “这句 Caption 很下头,按公众号读者的习惯改好。”
    - “从品牌和目标受众角度审校这组网站文案。”
    - “给 App 的空状态、按钮和错误提示写一套专业文案。”
    - “把策划案 / 海报 / 发布说明改成目标客户真正会读的语言。”
    - “Write audience-aware copy for this product surface.”
    - “Review this UI copy against our brand voice and user context.”
    
    ### Do not activate when
    
    - 用户只要求复现某位作者的个人文风;使用对应 writing-style 或 style-clone 能力。
    - 用户只要求检测 AI 套话、句式均匀或平台 AI 风险;使用 humanizer 或 anti-AI 审计。
    - 用户只要求营销落地页的转化结构、SEO 关键词或广告投放策略;使用 copywriting、
      landing 或 SEO 能力。本 Skill 可在其输出后做语境验收。
    - 用户只要求事实研究、翻译、排版、视觉设计或正式发布,不需要改动可见文案。
    
    ## User Profile
    
    每次运行读取 `skill.yaml` 声明的 `user-profile/v1` 上下文,按当前请求、项目上下文、
    本 Skill records、共享 preferences、brand/user Profile 和安全默认值解析。品牌名称、
    定位、语气、禁用表达与目标受众来自 Profile 或当前 brief,不在 Skill 源中硬编码。
    
    用户直接声明且希望跨任务沿用的文案偏好,通过:
    
        python3 scripts/profile_store.py record \
          --skill-id lov-branding-consistency \
          --path records.<field> \
          --value '<json>' \
          --confirm
    
    写回共享 Profile,并报告 canonical 路径。推断值、私有素材和凭据不得持久化。
    
    ## Skill Group Composition
    
    执行前读取 [Skill composition](references/skill-composition.md)。本 Skill 独占“让最终
    可见文案符合具体媒介、受众与品牌角色”的验收结果;相邻能力只通过 brief、草稿、
    品牌 Profile 或审计报告可选交接,不是隐藏依赖。
    
    ## Cross-Skill dependency contract
    
    当本 Skill 由另一个 Skill 的 `depends_on` 触发时:
    
    1. 只审校该 Skill 即将交付或发布的受众可见文本,不接管其事实、数据、代码或渠道职责。
    2. 引文、转录、源数据、法律文本、标识符、代码与用户原始输入默认保持逐字不变。
    3. 对标题、Caption、摘要、按钮、说明、CTA、章节名、发布描述等创作型字段执行在位验收。
    4. 修正文案直接回填目标制品;诊断和内部说明留在交付报告,不混入正文。
    5. 发布型 Skill 发现 hard failure 时先修复或停止,不能把“已调用品牌门禁”当作通过证据。
    
    ## Core rule: write from the audience side
    
    模型、设计师和运营者知道的制作事实,不自动属于读者。用户可见文案只能保留三类
    信息:帮助理解、帮助行动、完成必要归属。其余制作说明留在 alt、备注、manifest、
    设计稿标注或交付报告中。
    
    例如:
    
    - 内部事实:这是正文首图;Logo 是官方版本;画面由原作与 Logo 合成。
    - 读者需要:作品是谁的、叫什么、何时创作;必要时补馆藏或版权信息。
    - 专业 Caption:`Piet Mondrian,《Composition (No. 1) Gray-Red》,1935。`
    - 更合适的选择:若正文已有“封面里的作品”完整说明,首图可以不显示 Caption。
    
    ## Workflow (MANDATORY)
    
    ### Step 0: Resolve context and references
    
    1. 读取当前请求、相关界面或文档、共享 Profile 与本 Skill records。
    2. 完整读取 [Context contract](references/context-contract.md)。
    3. 按目标媒介读取 [Scene conventions](references/scene-conventions.md)。
    4. 输出前读取 [Quality gate](references/quality-gate.md)。
    5. 输入足够时直接推进;只有缺失项会改变品牌身份、受众或行动结果时才问一个问题。
    
    ### Step 1: Build a private context contract
    
    在内部解析九项,不默认展示:
    
    1. `surface`:公众号、网站、App、策划案、海报、邮件、社交媒体或其他;
    2. `component`:标题、Caption、按钮、提示、Hero、正文、CTA、表单、脚注等;
    3. `audience`:谁会看到,他们已知道什么,最在意什么;
    4. `moment`:浏览、比较、决策、输入、等待、失败、完成或分享;
    5. `job`:这句话唯一要完成的读者任务;
    6. `brand_role`:个人、产品、公司、媒体、专家或平台,此刻以何种身份说话;
    7. `tone`:由品牌 Profile 与情境共同决定,不从通用“专业感”猜口号;
    8. `constraints`:字符、层级、屏幕、平台语法、法务、无障碍与可验证事实;
    9. `visibility`:哪些事实给读者,哪些只留在制作链。
    
    普通任务不要把这份 contract 当作前言输出。它用于决策,不是用户制品。
    
    ### Step 2: Decide whether copy should exist
    
    先问三件事:
    
    1. 没有这句话,读者是否仍能正确理解或行动?
    2. 邻近标题、图片、控件或平台字段是否已经表达同一信息?
    3. 这句话是否只是在解释制作者做了什么?
    
    三问都不支持保留时,删除优于改写。零文案是合格结果,尤其适用于装饰图片、已经
    自明的正文首图、重复按钮说明和平台已展示的作者字段。
    
    ### Step 3: Separate visible copy from metadata
    
    建立可见性防火墙:
    
    - **Visible copy**:读者必须理解、决定或行动的信息。
    - **Accessibility text**:描述图片或控件本身,不承担营销和制作说明。
    - **Attribution**:作者、作品、日期、来源、版权等必要归属。
    - **Production metadata**:正文首图、官方 Logo、生成方式、导出规格、审批状态、
      文件名、组件名与实现说明;默认不出现在读者文案。
    
    Alt、Caption、设计标注和正文不是同一个字段,不得把一段内部描述复制到所有位置。
    
    ### Step 4: Draft by component convention
    
    1. 先写一句只完成 `job` 的核心版本。
    2. 用目标组件的专业惯例决定长度、句法、标点、称谓和信息顺序。
    3. 品牌通过选词、判断、节奏与克制体现,不自报“官方”“品牌化”“专业”。
    4. 删除读者已经看得见的事实、内部术语、解释性尾巴和防御性补充。
    5. 事实、名字、日期、版本、引用、权利状态和行动后果保持准确。
    6. 用户只要局部文案时,只交付局部,不附“我做了哪些优化”。
    
    ### Step 5: Review in place
    
    把文案放回真实邻接环境再检查:上一行、下一行、图片、按钮、页面标题、平台 author
    字段和移动端宽度。脱离组件单看“挺好”的句子,放回界面后可能重复、抢层级或像
    设计交付说明。
    
    可对短文案运行辅助审计:
    
        python3 scripts/copy_audit.py --text '待检查文案' \
          --surface wechat --component caption --format json
    
    脚本只定位可观察的元话语、制作术语与组件错配,不代替受众和品牌语义验收。
    
    ### Step 6: Apply the quality gate
    
    逐项判断:
    
    1. 说话者是否是目标品牌,而不是 AI、设计师或执行 Agent?
    2. 文案是否帮助目标受众完成此刻唯一任务?
    3. 是否暴露正文首图、官方 Logo、组件、生成、导出等制作链信息?
    4. 是否重复平台、界面或图片已经表达的内容?
    5. 是否把 alt、Caption、归属和正文混为一谈?
    6. 语气是否来自品牌与场景,而不是“专业、温暖、高级”等空洞形容词?
    7. 是否符合此组件的真实长度、句法、标点和行动后果?
    8. 删除后是否更好?如果是,删除。
    
    任何一项失败都先修复,再交付。
    
    ## Output Contract
    
    - 默认只输出可直接粘贴或写入目标位置的最终文案。
    - 审校请求先给一句结论,再给最高优先级问题;用户要求修改时直接附最终版本。
    - 存在多个合理方向时最多给三版,并明确差异来自受众或品牌策略,不堆同义句。
    - 不把内部 brief、推理步骤、制作说明、Prompt 或验收清单混入读者制品。
    - 不虚构品牌事实、用户研究、数据、评价、权利状态或产品承诺。
    
    ## Dependencies
    
    核心能力为 instruction-first,无网络、凭据或 sibling Skill 强依赖。Python 3.8+
    用于可选本地审计和 Profile 存储;完整源校验需要 PyYAML。
    
  • skill.yaml 2.1 KB
    schema: skill-manifest/v1
    id: lov-branding-consistency
    version: "0.2.0"
    runtime: skill-runtime/v1
    context:
      profile:
        schema: user-profile/v1
        source: shared-profile
        read:
          - user
          - brand
          - workspace
          - preferences
          - skills.lov-branding-consistency
        persist:
          enabled: true
          namespace: skills.lov-branding-consistency
          records_path: skills.lov-branding-consistency.records
          write_policy: direct-user-statement
          atomic: true
        fields:
          - path: user.name
            aliases:
              - identity.name
            required: false
            question: 如果本次输出需要用户身份,请提供名称。
          - path: user.language
            required: false
            question: 希望使用哪种语言输出?
          - path: user.timezone
            required: false
            question: 需要使用哪个时区处理日期和时间?
          - path: brand.name
            aliases:
              - identity.name
            required: false
            question: 如果本次输出需要品牌身份,请提供品牌名称。
          - path: brand.site
            required: false
            question: 如果需要品牌官网,请提供地址。
          - path: brand.tone
            required: false
            question: 如果已有品牌语气或审美关键词,请提供它们。
          - path: brand.profile
            required: false
            question: 如果已有更完整的品牌表达规范,请提供其内容或位置。
          - path: skills.lov-branding-consistency.profile.default_audience
            required: false
            question: 如果希望长期使用一个默认目标受众,请提供受众描述。
          - path: skills.lov-branding-consistency.profile.default_surface
            required: false
            question: 如果希望长期使用一个默认发布场景,请提供场景名称。
      preferences:
        namespace: lov_branding_consistency
        fields:
          - path: user.language
            required: false
            question: 希望使用哪种语言输出?
          - path: user.timezone
            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