Compare commits

..

6 Commits

Author SHA1 Message Date
e32d04ec50 Release v1.0.114: add sync.ps1 so new skills can mirror plaintext locally
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-09-24 10:06:34 +08:00
a8403ce9b4 Release v1.0.113: document default_sort may use hidden columns
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-09-23 10:14:20 +08:00
8c8811e777 Allow skill slugs without a platform suffix.
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-09-21 11:30:38 +08:00
a70da6a076 feat: precipitate grid presentation (row/column/region) into skill contract
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-09-20 21:08:23 +08:00
a125b00285 Release v1.0.110: 沉淀壳子页与页面调用契约
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-09-19 16:23:23 +08:00
2a3e971c76 docs: document JobStopped and lease cleanup contract for interrupt
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-09-17 13:26:28 +08:00
42 changed files with 1531 additions and 161 deletions

View File

@@ -9,6 +9,37 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.114
- 技能根目录增加 `sync.ps1`,和 `release.ps1` 并排。在本机把明文同步到已安装的技能目录,用来在宿主里验收
- 市场上的最新版本和加密包仍走 `release.ps1`。同步后退出资料管理再打开,不要点技能市场的「更新」
## 1.0.113
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错
## 1.0.112
- 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可
- 不要再把 `default` 当作占位平台。已经发布、名字末尾是 `default` 的技能不用改名
## 1.0.111
- 沉淀数据管理呈现加法:同一 `cellPresentation.enabled` 支持单元格、整行、整列、行列交叉区域(闭集 tone)
- 新增 `_jiangchang_grid_presentation` 契约 / Schema / 示例;v1 `cells[]` 保持不变;禁止整表 `all`×`all`;整行色只涂表格、不铺到详情字段
- 内部隐藏表补上两张呈现旁路表;模板默认仍不启用着色
## 1.0.110
- 数据管理侧栏除了生成出来的 HTML 快照,也会直接显示安装包里 `assets/html/pages/` 的页面,装完就有,不用点生成
- 这些页面可以调用本技能已经声明的命令:宿主代为执行 Python,把 JSON 交回页面
## 1.0.109
- 沉淀任务中断与账号租约契约:禁止用裸 `except Exception` 吞掉 `JobStopped`;`finally` 先释租约再关浏览器;宿主只通知不代收租约
- 补充 CHECKLIST A5-13(中断后租约须释放)及 ACCOUNT_MANAGER / LOGGING / ADAPTER / 样例 README
## 1.0.108
- 沉淀数据管理「用户可见文案」契约:弹窗 description / 确认黄条 / 参数说明 / 任务失败 message 须全中文产品用语

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.108
version: 1.0.114
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -42,9 +42,9 @@ allowed-tools:
- **用户可见文案**:数据管理弹窗 / 任务中心文案须全中文产品用语;勿把 `pickCount`、`async`、`pending`、CLI 子命令写进弹窗(见 `references/ACTIONS.md`「用户可见文案」)。
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。
- **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。
- **`cellPresentation`(可选)**:需要数据管理「某格 / 整行 / 整列 / 区域」特殊标色时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。
- **业务行生命周期(有处理队列时)**:对某一行做生成/触达/私信等处理时,默认只操作「可操作」角色状态;开始抢占「进行中」;结束回写成功或失败(失败带原因);重做靠人工改回可操作。状态名由本技能自定。详见 `references/WORK_ITEM_STATUS.md`。
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;文件型特殊报告才另做 `export-*`(落 `output/`);**HTML 整页报告**写 `html/` 并用固定 Action id `render-html`(见 `references/ACTIONS.md`)。
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;文件型特殊报告才另做 `export-*`(落 `output/`);**HTML**:壳子页放 `assets/html/pages/`,快照写 `html/` 并用固定 Action id `render-html`;页面用 `jiangchang.call` 调已声明命令(见 `references/ACTIONS.md`)。
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
### 如何触发(能力类型)
@@ -159,7 +159,7 @@ python {baseDir}/scripts/main.py init-db
## 技能命名与 slug
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
- 标准格式:`{verb}-{noun-phrase}`,绑定外部平台时才加 `-{platform}`(详见 `development/NAMING.md`)。不要用 `default` 占位。
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case;`name`、`description`、根 `README.md`:**中文**。
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。
- `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。

View File

@@ -2,7 +2,8 @@
- `actions.json`:Skill Action manifest 最小示例(按入口定位表决定是否保留/扩展;见 `development/PRODUCT_SURFACES.md`)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。
- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。
- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。
- `html/example-report.html`:HTML 快照页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`。不要放进 `pages/`。
- `html/pages/*.html`:装完就出现在侧栏的壳子页(宿主直接读安装包,不经生成)。页面用 `jiangchang.call` 调本技能已声明的命令。见 `references/ACTIONS.md`「HTML 页面报告」。模板仓库不要在这里放示例页。
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。

View File

@@ -3,6 +3,7 @@
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
- `skill-grid-presentation.example.json`:数据管理整行 / 整列 / 区域呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。

View File

@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"generatedAt": "2026-08-10T06:00:00Z",
"rules": [
{
"ruleKey": "qty-low-rows",
"rows": ["42", "99"],
"columns": "all",
"tone": "danger",
"message": "库存低于安全线",
"alert": true
},
{
"ruleKey": "status-column",
"rows": "all",
"columns": ["status"],
"tone": "warning",
"message": "待审核"
},
{
"ruleKey": "overdue-print-region",
"rows": ["7", "8"],
"columns": ["print_done_at", "ship_done_at"],
"tone": "danger",
"message": "环节到期未填",
"alert": true
}
]
}

View File

@@ -11,3 +11,4 @@ lianlian
single-window
export-rebate
etax
colipu

View File

@@ -1,4 +1,3 @@
all
batch
multi
default

View File

@@ -28,7 +28,7 @@
},
"cellPresentation": {
"$ref": "#/$defs/cellPresentationDeclaration",
"description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
"description": "可选。启用数据管理呈现(P1:格子/行/列/区域)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
}
},
"$defs": {

View File

@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
"title": "SkillCellPresentationDocument",
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。整行/整列/区域见 skill-grid-presentation.schema.json,同一 cellPresentation.enabled。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
"type": "object",
"required": ["schemaVersion", "skill", "table", "rowKey", "cells"],
"additionalProperties": false,

View File

@@ -0,0 +1,93 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-grid-presentation.schema.json",
"title": "SkillGridPresentationDocument",
"description": "匠厂数据管理「Grid Presentation」文档(schemaVersion 1)。技能按业务规则为 rows×columns 声明语义 tone(整行、整列、区域或单格);禁止 all×all。非任意 CSS。与 skill-cell-presentation.schema.json(v1 cells[])并存,同一 cellPresentation.enabled。详见 references/CELL_PRESENTATION.md。",
"type": "object",
"required": ["schemaVersion", "skill", "table", "rowKey", "rules"],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 1,
"description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致"
},
"table": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "业务表物理名(snake_case)"
},
"rowKey": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "用于匹配行的业务表列名(如 id);rules[].rows 的 id 取自该列"
},
"generatedAt": {
"type": "string",
"minLength": 1,
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
},
"rules": {
"type": "array",
"description": "当前应对该表生效的 grid 规则;空数组 = 该表无 grid 呈现(清除旧行/列/区域样式)。同 ruleKey 由 UNIQUE 约束覆盖。",
"items": { "$ref": "#/$defs/ruleItem" }
}
},
"$defs": {
"tone": {
"type": "string",
"enum": ["danger", "warning", "info", "success", "muted"],
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
},
"axisTarget": {
"description": "\"all\" = 该轴全部;非空字符串数组 = 有限集合(行 id 或列名)",
"oneOf": [
{ "type": "string", "const": "all" },
{
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
}
]
},
"ruleItem": {
"type": "object",
"required": ["ruleKey", "rows", "columns", "tone"],
"additionalProperties": false,
"not": {
"properties": {
"rows": { "const": "all" },
"columns": { "const": "all" }
},
"required": ["rows", "columns"]
},
"properties": {
"ruleKey": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "本表内唯一。建议稳定业务键(重算时覆盖同一条)"
},
"rows": { "$ref": "#/$defs/axisTarget" },
"columns": { "$ref": "#/$defs/axisTarget" },
"tone": { "$ref": "#/$defs/tone" },
"message": {
"type": "string",
"maxLength": 500,
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
},
"alert": {
"type": "boolean",
"description": "可选,默认 false。true = 按本条规则计入进页/同步提醒(不按命中格子数)"
}
}
}
}
}

View File

@@ -173,7 +173,9 @@ python scripts/main.py account open <account_id>
- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。
- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。
- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。
- **清理顺序**:`finally` 内**先 `release_lease`,再关浏览器**。关浏览器(含 CDP / 强杀 Profile 进程)可能耗时或卡住;先释租约可避免「窗还没关完、账号已被下一任务抢走失败」的空窗拉长。
- **任务中心中断**:宿主只下发 `stop` 并等待技能退出;技能须在闸门抛出 `JobStopped` 后进入 `finally` 自清。禁止用 `except Exception` 吞掉 `JobStopped`(详见 [`LOGGING.md`](LOGGING.md) §2.5)。不要指望宿主代收租约。
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理;亦可等待 TTL 过期。
- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。
## 9. 工业级测试
@@ -184,6 +186,7 @@ python scripts/main.py account open <account_id>
- 账号不存在、平台不匹配、Profile 缺失、返回格式异常;
- 同一账号并发抢占和 `LEASE_CONFLICT`;
- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放;
- **NEED_MANUAL** 任务中心运行中点「中断」:租约变为 `released`(或无 active),马上再采不因残留租约 `LEASE_CONFLICT`;
- 登录过期、验证码 / 二次验证、账号风险和权限不足;
- 两个以上账号轮换且 Profile、登录态、结果互不污染;
- 关闭重开后登录保持;

View File

@@ -102,7 +102,7 @@ def get_adapter():
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。
5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。
5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally` **先 `release_lease`、再关浏览器**;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。宿主中断只通知技能,**不**代收租约;禁止 `except Exception` 吞掉 `JobStopped`(见 [`LOGGING.md`](LOGGING.md) §2.5)。
6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。
7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → 校验指定账号后带租约 pick;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id --lease`;皆空 → 按平台 `ensure-web --lease`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配或无法确保拿到指定 ID → `ERROR:AMBIGUOUS_ACCOUNT`。
8. **运行前校验**:`profile_dir` 和 `lease_token` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。
@@ -119,10 +119,12 @@ result = call_sibling_json("account-manager", ["list", "--limit", "10"])
from service.account_client import pick_web_account, release_lease
account = pick_web_account(platform="target_platform", login_id=None)
lease_token = account.get("lease_token")
try:
...
finally:
release_lease(account.get("lease_token"))
release_lease(lease_token) # 先释租约
# 再关浏览器 / Playwright
```
## 相关文档

View File

@@ -60,7 +60,7 @@ checklist_version: 1.0
- [ ] S0-9 **入口定位表**(见 `PRODUCT_SURFACES.md` / `REQUIREMENTS` §0.1):已填写 / 未填写
- [ ] S0-10 本技能宣称的主用户路径涉及:对话 / 数据管理(执行或仅展示) / 定时(可多选;与定位表一致)
- [ ] S0-11 **业务行生命周期**(见 `references/WORK_ITEM_STATUS.md` / `REQUIREMENTS` §0.3):有处理队列且已声明状态→角色映射 / 不适用
- [ ] S0-12 **HTML 页面报告**(见 `references/ACTIONS.md`「HTML 页面报告」):有侧栏可预览的 `html/*.html` + `render-html` / 不适用
- [ ] S0-12 **HTML 页面报告**(见 `references/ACTIONS.md`「HTML 页面报告」):壳子页 `assets/html/pages/*.html` / 快照 `html/*.html` + `render-html` / 不适用
---
@@ -125,10 +125,11 @@ checklist_version: 1.0
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放;`finally` 先释租约再关浏览器
- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录
- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染
- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理
- [ ] A5-13 **P0** **NEED_MANUAL** 任务中心运行中点「中断」:技能响应 `JobStopped` 并释放租约(数据管理「账号租约」为 `released` / 无 active);立即再跑不因残留租约失败;进度 `emit` 不得用裸 `except Exception` 吞掉停止
---
@@ -148,6 +149,7 @@ checklist_version: 1.0
- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A**
- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`)
- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`)
- [ ] A6-15 **P1** 若启用 `cellPresentation`:旁路表不得出现在数据管理侧栏;整行着色不得铺到详情每个字段;禁止 `all`×`all`;tone 闭集。未启用 → **N/A**(见 `references/CELL_PRESENTATION.md`)
---
@@ -190,6 +192,7 @@ checklist_version: 1.0
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」
- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节
- [ ] A9-4b **P1** 本机宿主验收用根目录 `.\sync.ps1` 覆盖已安装明文;市场上的最新版本和加密包仍走 `.\release.ps1`。同步后退出资料管理再打开,不要点技能市场「更新」
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
---

View File

@@ -70,7 +70,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
- 工具栏「导入」**UI 一律**打开宿主「导入数据」面板(默认从本技能 `input/` 选文件);「导出」默认写到 `output/`
- **Action 表单**里 `display_type`/`format` 为 `file` 或 `directory` 时:宿主原生对话框默认打开**当前技能** `{skill_data}/input`;若字段声明可选 **`pathRoot`**(相对技能数据根,如 `input/库存数据`),则打开该子目录。禁止在技能里写用户 ID 或绝对盘符。契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「本地路径选择与 pathRoot」
- 若表声明了 `replaces: import`,确认导入后由技能 CLI 写库(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「`replaces`」);仅有 `replaces: create` 时宿主可按行调 create(兼容)
- 数据管理扫描 `{skill_data}/html/*.html`(一层、非递归);`<title>` → 侧栏显示名;「重新生成」调用 Action id **`render-html`**(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」)
- 数据管理扫描两处 HTML(都只扫一层):安装包 `assets/html/pages/*.html` 是装完就有的壳子页;`{skill_data}/html/*.html` 是快照,「重新生成」调用 Action id **`render-html`**。页面里用 `jiangchang.call` 调本技能已声明的命令。详见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」
---

View File

@@ -74,6 +74,7 @@ your-skill/
├─ CHANGELOG.md
├─ requirements.txt
├─ release.ps1
├─ sync.ps1
└─ SKILL.md
```
@@ -85,6 +86,8 @@ your-skill/
- `DEMO.md`:市场 **演示** Tab(`demo_video_url` + `demo_md`)
- `CHANGELOG.md`:市场 **更新日志** Tab(按版本小节同步 `changelog`)
- `SKILL.md`:面向 LLM / 平台的技能入口
- `release.ps1`:发布到服务器(提交、tag、CI 加密包)
- `sync.ps1`:把明文同步到本机宿主,供界面验收
- `references/`:Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA)
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
- `scripts/`:放真正的代码
@@ -173,6 +176,7 @@ scripts/
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- 根目录 `sync.ps1` 是开发者手动执行的本机同步,允许用 `uv pip install` 把本技能 `requirements.txt` 装进宿主共享 venv。业务代码和其它脚本仍然不要自己装包。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
- `pkg>=1.2.0,<2`
- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限:
@@ -635,7 +639,7 @@ cmd_stats() # 只读 sync:JSON,无 job_context
- 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。
- **顺序执行、不并发**同一浏览器 Profile / account lease。
- **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。
- **HTML 页面报告**(可选):需要侧栏预览整页时,写入 `html/` + Action `render-html`;契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」(含**宿主预览布局**底线)、[`DATA_PATHS.md`](DATA_PATHS.md);薄写入 helper 见 `scripts/service/html_page_service.py`。建议从 `assets/html/example-report.html` 复制壳;`{{BODY}}` 内数据须转义;样式升级后提醒用户重新生成。
- **HTML 页面报告**(可选):壳子页放 `assets/html/pages/*.html`,装完即进侧栏;快照才写入 `html/` + Action `render-html`。页面用 `jiangchang.call` 调已声明命令。契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」(含**宿主预览布局**底线)、[`DATA_PATHS.md`](DATA_PATHS.md)。快照可从 `assets/html/example-report.html` 复制壳,不要把该文件放进 `pages/`。
- 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。
### 队列 + pick(可选)
@@ -747,6 +751,33 @@ python scripts/main.py <your-command>
详见 `TESTING.md`。
## 14.6 同步到本机宿主
`sync.ps1` 和 `release.ps1` 并排,都在技能根目录执行。`release.ps1` 发布到服务器并触发加密打包;`sync.ps1` 把当前明文覆盖到本机已安装的技能目录,用来在宿主里验收,不必每次都走服务器构建。
在技能根目录:
```powershell
.\sync.ps1
.\sync.ps1 -DryRun
.\sync.ps1 -SkipDeps
.\sync.ps1 -ForceDeps
.\sync.ps1 -SkipInitDb
```
脚本会:
- 按 `SKILL.md` 的 slug 镜像到 `%USERPROFILE%\.openclaw\skills\<slug>`,排除 `.git` 等开发目录
- 去掉安装目录里残留的 `pyarmor_runtime_*`
- 把 `requirements.txt` 装进宿主共享 venv(与宿主安装技能同一条路径)。依赖未变时可跳过;`-SkipDeps` 只跳过安装,仍会跑 `init-db`
- 用宿主 `settings.json` 里的当前登录用户跑 `scripts\main.py init-db`。没有登录用户时停止,不会猜用户 ID
同步完成后:先停掉该技能正在跑的任务,退出资料管理再打开。不要点技能市场的「更新」,那会用加密包装掉这份明文。
技能至少要从市场安装过一次,宿主才会启用它。`sync.ps1` 覆盖的是已经装上的那一份。市场上的「最新版本」仍只在 `release.ps1` 的 CI 成功后变化;本机「当前版本」来自这次同步写进去的 `SKILL.md`。
`sync.ps1` 须为 UTF-8 无 BOM,且不要写中文:Windows PowerShell 5.1 会把无 BOM 的中文脚本解析错。
## 15. 发布到正式环境验证
### release workflow 通道
@@ -894,7 +925,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|-------------------------|----------------|--------------|
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
| **数据管理 · HTML 报告** | 写出 `html/*.html`;提供 Action id `render-html` | 侧栏出现报告项;预览正常;右键「重新生成」成功且时间/内容更新 |
| **数据管理 · HTML 报告** | 壳子页放 `assets/html/pages/*.html`;快照写出 `html/*.html` 并提供 `render-html` | 壳子装完即现、右键无「重新生成」;快照可重新生成;页面 `jiangchang.call` 能拿到 JSON |
| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
@@ -925,7 +956,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
每个新 skill 发布前,建议技术人员逐条确认:
- [ ] 目录结构符合当前模板
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform)
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(动词-名词,平台可选;不要用 `default` 占位)
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID(设置 → 用户信息 → 复制),不是模板示例 ID

View File

@@ -122,6 +122,26 @@ RPA 等待用 `interruptible_sleep`,**不要**裸 `asyncio.sleep`(否则暂
Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`),供宿主任务中心显示状态。
**宿主中断(`stop`)与 `JobStopped`(必读):**
- 用户在任务中心点「中断」时,宿主写入 `control.json` `command=stop`,并**等待技能进程协作退出**(超时后才可能强杀)。
- 技能在 `emit` / `checkpoint` / `interruptible_sleep` / `rpa_step` 等闸门处会抛出 `JobStopped`;业务代码须让其冒泡到 `job_context` / 外层 `finally`,由技能**自己**释放租约、关闭浏览器等资源。
- **禁止**用裸 `except Exception:` 包住上述闸门调用。`JobStopped` 是 `Exception` 子类,会被吞掉,导致 UI 已停、进程仍占账号租约,下次采集必 `LEASE_CONFLICT`。
- 若必须兜底「emit 写 Journal 失败」,正确写法:
```python
from jiangchang_skill_core.activity import JobStopped, emit
try:
emit("正在处理…", skill=SKILL_SLUG, stage="fetch")
except JobStopped:
raise
except Exception:
log.debug("emit_failed", exc_info=True)
```
- 宿主**不会**按技能类型统一代收 account-manager 租约(许多技能根本不用账号管理)。强杀后的残留靠租约 TTL / 数据管理手工 force-release;主路径必须是技能自清。
### 2.3 可观测性三分法(禁止 task_logs)
| 信息类型 | 去哪里 | 技能怎么做 |

View File

@@ -13,23 +13,24 @@
| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 |
| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 |
**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。
**新技能**使用「动词-名词」,外部平台可选。不要再为了凑段数追加 `default`。校验器不追溯已上线仓库;已经发布、末尾是 `default` 的技能不用改名。
---
## 2. 命名格式
标准形态(约 95% 新技能):
标准形态:
```text
{verb}-{noun-phrase}-{platform}
{verb}-{noun-phrase}
{verb}-{noun-phrase}-{platform} # 仅当技能绑定外部平台时
```
| 部分 | 规则 |
|------|------|
| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) |
| **noun-phrase** | 1~3 个词(按 `-` 分段),描述业务对象 |
| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) |
| **platform** | **可选**。没有抖音、小红书、亚马逊这类外部系统时省略。有平台时 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) |
**展示层分工不变:**
@@ -44,8 +45,8 @@
| 约束 | 值 |
|------|-----|
| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) |
| 总段数(按 `-` 分割) | **3~5 段**(标准型);scope 型见 [§4](#4-三类合法形态) |
| 格式 | `{verb}-{noun-phrase}`;有外部平台时才加 `-{platform}` |
| 总段数(按 `-` 分割) | **2~5 段** |
| 总长度 | **≤ 48 字符** |
| 字符集 | 小写字母、数字、连字符;kebab-case |
| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx`) |
@@ -56,12 +57,26 @@
## 4. 三类合法形态
### 4.1 标准型(95%)
### 4.1 无平台(常见)
`verb-noun-platform` 或 `verb-noun-noun-platform`(3~5 段)
`verb-noun` 或 `verb-noun-noun`(2~4 段)
- 第 1 段 ∈ 动词白名单
- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单
- 其余为 noun-phrase(1~3 段)
- **不要**追加 `default`
示例:
- `generate-monthly-statements`
- `reconcile-finance`
- `process-article`
### 4.2 标准型(绑定外部平台)
`verb-noun-platform`(在无平台形态后加平台,总段数仍 ≤ 5)
- 第 1 段 ∈ 动词白名单
- 最后 1 段(或 hyphenated 平台的最后若干段)∈ 平台白名单
- 中间为 noun-phrase(1~3 段)
示例:
@@ -71,19 +86,19 @@
- `enter-sales-receipt-kingdee`
- `fill-export-declaration-single-window`
### 4.2 scope 型(少见)
### 4.3 scope 型(少见)
`verb-noun-scope`
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default`
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi`
- 第 1 段 ∈ 动词白名单
- 总段数 3~5
- 至少还有一段名词(因此最少 3 段)
示例:`generate-article-all`
> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。
> `default` 已从 scope 词表移除。它不是平台,也不表示范围。新技能不要写 `generate-article-default` 这类占位。已发布且末尾为 `default` 的仓库保持原 slug;同步本校验后只会警告,不会因此失败。
### 4.3 历史豁免(不校验语义)
### 4.4 历史豁免(不校验语义)
以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`):
@@ -121,7 +136,7 @@
| lianlian | single-window | export-rebate |
| etax | | |
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`、`default`
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`。不含 `default`。
---
@@ -164,7 +179,7 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
## 9. 复制新技能流程
1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符
1. **先定 slug** — `{verb}-{noun-phrase}`,仅在绑定外部平台时加 `-{platform}`。确认动词/平台在白名单内且 ≤ 48 字符。不要用 `default` 占位。
2. **复制目录** — **推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(`-Slug` 与目录名一致);勿整包复制带走 `.git`
2.5. **清理 Git(若未用 scaffold)** — 删除 `.git`、`.openclaw-skill-template`、`.pytest_cache`;再 `git init` 与 `git remote add origin`
3. **全局替换** — `your-skill-slug` → 新 slug;更新 `SKILL.md`、`constants.py`、文档中的占位
@@ -179,10 +194,12 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
| slug | 说明 |
|------|------|
| `download-settlement-report-amazon` | 标准型,4 段 |
| `add-product-shopee` | 标准型,3 段 |
| `enter-sales-receipt-kingdee` | 标准型 |
| `fill-export-declaration-single-window` | 标准型,平台含连字符 |
| `generate-monthly-statements` | 无平台 |
| `reconcile-finance` | 无平台,动词 + 一个名词 |
| `download-settlement-report-amazon` | 有平台 |
| `add-product-shopee` | 有平台 |
| `enter-sales-receipt-kingdee` | 有平台 |
| `fill-export-declaration-single-window` | 有平台,平台含连字符 |
| `generate-article-all` | scope 型 |
### ❌ 反例
@@ -191,7 +208,7 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|------|------|
| `amazon-download-settlement-report` | 平台在最前 |
| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 |
| `reconcile-finance` | 新技能禁止 2 段无平台 |
| `generate-article-default` | `default` 不是平台;应写成 `generate-article` |
| `download_settlement_amazon` | 非 kebab-case(下划线) |
---
@@ -201,14 +218,14 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
- **平台前置**:`amazon-download-...`、`shopee-add-...`
- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称
- **只改目录名**:`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步
- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读
- **仿历史 2 段 repo**:`reconcile-finance`、`content-manager` 等
- **用 default 占位**:`generate-article-default`。没有外部平台就省略末段
- **名词堆砌**:中间段过多导致超 48 字符或难以阅读
---
## 12. 演进说明(当前不启用)
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**。新技能使用动词-名词;只有绑定外部平台时才使用 verb-noun-platform,或使用 scope 型。
---
@@ -218,9 +235,9 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
| 历史 slug | 说明 |
|-----------|------|
| `account-manager` | 跨平台账号管理,非 verb-noun-platform |
| `reconcile-finance` | 2 段无 platform |
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) |
| `account-manager` | 跨平台账号管理,非动词-名词 |
| `reconcile-finance` | 现已是合法的无平台形态;旧文档曾把它标成反例 |
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家);新技能若要表达链家,先把平台加入白名单 |
| `skill-template` | 模板仓库本身 |
校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。

View File

@@ -9,7 +9,7 @@
| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtime;development/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py`) |
| POLICY-RUNTIME-003 | Agent/CLI 文档须要求共享 Python(python-runtime / PATH 上的 `python` + `{baseDir}`);须明确**禁止**对技能脚本使用 `uv run python` | development/RUNTIME.md;references/CLI.md;SKILL.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_cli_md_requires_shared_python_not_uv_run`;`tests/test_docs_standards.py::test_skill_md_requires_shared_python_not_uv_run` |
| POLICY-AGENT-001 | `SKILL.md` 须含「用户意图路由表」结构;`CLI.md` 须含短 CLI 白名单 / Action 强制黑名单表;用法回答须禁止无故粘贴 CLI、禁止为答用法列目录挖 references | SKILL.md;references/CLI.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_skill_md_has_intent_routing_table`;`tests/test_docs_standards.py::test_cli_md_has_agent_routing_tables`;`tests/test_docs_standards.py::test_skill_md_how_to_forbids_cli_paste` |
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`;根目录 `sync.ps1` 除外,它是开发者手动执行的本机同步,用 `uv pip install` 装进宿主共享 venv) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` |
| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env` 与 `*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` |
| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrap;development/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` |
@@ -60,7 +60,7 @@
| 是否满足完整快照语义 / 空结果保护实现 | references/SCHEMA.md | 见 `test_sync_contract.py.sample` |
| 是否把系统导出与业务同步混淆 | references/ACTIONS.md;SCHEMA.md | 文档 + sample;勿做源码关键词误报扫描 |
| `db/` 层不得调浏览器或拼业务流程 | development/DEVELOPMENT.md §11 | 需跨文件调用图 |
| slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
| slug 命名语义(动词-名词,平台可选) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 |
| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` |
| RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 |

View File

@@ -21,7 +21,7 @@
1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。
2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **`visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** Policy L3 行态 `visibleWhen` 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`bind.selectionMode`**(见 ACTIONS.md):表勾选如何驱动 Action(`required` / `none` / `optional`);省略 = 宿主按 `$selection.ids` / `batch` 推断。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。**用户可见文案**(见 ACTIONS.md「用户可见文案」):弹窗 `description` / 确认黄条 / 参数说明 / 任务失败 `message` 须全中文产品用语;Agent 路由勿塞进弹窗。
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **`visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** Policy L3 行态 `visibleWhen` 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`bind.selectionMode`**(见 ACTIONS.md):表勾选如何驱动 Action(`required` / `none` / `optional`);省略 = 宿主按 `$selection.ids` / `batch` 推断。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格/整行/整列/区域语义标色;省略 = P0。**用户可见文案**(见 ACTIONS.md「用户可见文案」):弹窗 `description` / 确认黄条 / 参数说明 / 任务失败 `message` 须全中文产品用语;Agent 路由勿塞进弹窗。
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。
6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。
@@ -167,7 +167,7 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
| 同步 / refresh / collect | 外部来源 → 技能本地 SQLite | 技能 domain service |
| 导出当前数据表 | 本地库 → CSV/Excel | **宿主**数据管理统一导出 |
| 特殊业务报告(文件) | 多表合并 / 特殊格式 → `output/` | 仅系统导出不足时提供 `export-*` |
| HTML 页面报告 | 写入 `{skill_data}/html/*.html` | 技能生成;宿主扫描预览;重新生成 Action id 固定为 **`render-html`** |
| HTML 页面报告 | 壳子页 `assets/html/pages/*.html`;快照写入 `{skill_data}/html/*.html` | 壳子装完即现;快照由技能生成,重新生成 Action id 固定为 **`render-html`**。页面用 `jiangchang.call` 调已声明命令 |
同步 Action 默认写库、不写导出文件。HTML 报告契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」与 [`DATA_PATHS.md`](DATA_PATHS.md)。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。

View File

@@ -34,8 +34,8 @@
- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
- **开发规范守护(hard policy)**:[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。
- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线;业务技能若有自定义 `assets/html/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——动词-名词命名;平台可选,`default` 不再作为占位。
- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线,并守护 ACTIONS.md 里的壳子页目录 `assets/html/pages` 与 `jiangchang.call`。业务技能若有自定义 `assets/html/*.html` 或 `pages/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。不要把 `example-report.html` 放进 `pages/`。
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。

View File

@@ -52,7 +52,7 @@ real_browser_rpa/
8. **等待结果容器** — `#search-result-container`(示例 selector)
9. **滚动采集循环** — 采集可见条目 → 判断 stop_reason → 滚动加载
10. **返回结果** — `ScrapeRunResult` + `format_stop_reason_for_user()`
11. **释放租约** — `finally` 中 `release_lease()`
11. **释放租约** — `finally` 中**先** `release_lease()`,**再**关浏览器;进度 `emit` 不得用裸 `except Exception` 吞掉 `JobStopped`
## 开发新 skill 时如何复制
@@ -150,7 +150,7 @@ real_browser_rpa/
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理
- **日志脱敏** — 不输出完整手机号/账号
- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放
- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` **先**释放租约再关浏览器;任务中心中断靠 `JobStopped` 进 `finally`,禁止吞掉停止信号
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
## 常见坑

View File

@@ -55,7 +55,7 @@ simulator_browser_rpa/
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) |
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally` 先 `release_lease` 再关浏览器 |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 |

View File

@@ -77,7 +77,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 |
| `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) |
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格/整行/整列呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)、[`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json) 与 [`assets/schemas/skill-grid-presentation.schema.json`](../assets/schemas/skill-grid-presentation.schema.json)。
## 六职责正交(必读)
@@ -91,7 +91,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
| **`hoverHint`** | **可选**:数据管理**工具栏悬停**文案 | 不决定短按钮名;不决定点下去弹不弹窗;**不回退** `description` / `confirmation.message`;省略 = 不显示悬停。当前宿主实现为顶栏技能按钮 |
| **`inputSchema.visibleWhen`** | **可选**:Action **参数表单**字段条件显隐(先选模式再出对应参数) | **不是** Data Policy L3 行态 `visibleWhen`;不决定 placements;旧宿主忽略则字段平铺全显 |
| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 |
| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) |
| **`cellPresentation`** | **可选**:某表某格 / 整行 / 整列 / 区域的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) |
| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
@@ -102,14 +102,14 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
| 层级 | 稳定字段 / 能力 |
|------|----------------|
| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) |
| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(格子/行/列/区域呈现启用块) |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `hoverHint`、`inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) |
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` \| `"import"` — 接管默认新增/编辑/删除/导入(须 `bind.tables`) |
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`、`columnMapping`、**`labelColumn`**、**`selectionMode`**(行内/批量预填、选择摘要标签、勾选驱动模式,见下节) |
| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 |
| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载格子与 grid 呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly`、可选 **`revealInForm`**;可选 `display_type` / `format`(含 `file` / `directory` 等);可选 **`pathRoot`**(file/directory 起始目录,相对技能数据根);可选 **`visibleWhen`**(字段条件显隐,见专节) |
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **hoverHint** | 可选字符串。数据管理**工具栏悬停**文案;与 `description` / `confirmation.message` **独立**。省略则不显示悬停,不回退。短名已够懂时不要写。需宿主已支持该字段;当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出 |
@@ -776,6 +776,7 @@ HINT: <next step>
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。
- 误以为 `visible=0` 的列不能出现在 `default_sort_json`——隐藏只影响展示/筛选/搜索;ORDER BY 仍可用非敏感物理列(含隐藏主键)。
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
### Action 类型 × placements(常见示例,不是限制)
@@ -801,28 +802,55 @@ HINT: <next step>
| **同步数据**(sync / refresh / collect) | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
| **导出当前数据表** | 技能本地数据库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的 **xlsx/csv/json 等文件** | 仅当系统导出无法满足时,才提供独立 `export-*` Action;文件落 `output/` |
| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 技能写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) |
| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 壳子页放安装包 `assets/html/pages/`;快照写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) |
同步型 Action 默认:读外部数据、写技能自己的库、**不**生成 CSV/Excel/JSON 文件、不重复实现宿主表导出。兼容旧 export 命令若需要刷新数据,必须调用同一个 sync 业务内核。通用同步语义见 [`SCHEMA.md`](SCHEMA.md) 与 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
## HTML 页面报告(数据管理预览)
> 一句话:**技能负责生成并覆盖 `html/*.html`;宿主负责发现、预览、用 `render-html` 触发重新生成。**
> 一句话:**壳子页跟安装包走,装完就在侧栏;快照由技能写入 `html/*.html`,宿主用 `render-html` 重新生成。** 页面上的按钮通过宿主调用本技能已声明的命令,不自己起 Python。
### 何时用
- 需要**可浏览的整页报告**(KPI + 表格 + 自带样式),而不是再导出一张表
- 与 `export-*` / 宿主表导出**并列**:HTML 走 `html/`;文件型特殊报告仍走 `output/` + `export-*`
- 需要**可浏览的整页**(KPI + 表格 + 自带样式),而不是再导出一张表
- **壳子页**:样式和脚本随技能安装就有,数字在打开后向宿主要。不要为了让它出现而点「生成 HTML」,也不要在 `init-db` 里拷进数据目录
- **快照**:把算好的报告烘进文件(往来对账单一类)。与 `export-*` / 宿主表导出**并列**:快照走 `html/`;文件型特殊报告仍走 `output/` + `export-*`
### 目录与发现(宿主契约)
| 项 | 约定 |
|----|------|
| 路径 | `{skill_data}/html/*.html`,**只扫一层**(子目录忽略) |
| helper | `util.runtime_paths.get_html_dir()`;写入用 `service.html_page_service.write_html_page` |
| 壳子页 | 安装包 `assets/html/pages/*.html`,**只扫一层**(子目录忽略)。装完即进侧栏,不经 `render-html` |
| 快照 | `{skill_data}/html/*.html`,**只扫一层**。helper:`get_html_dir()` / `write_html_page` |
| 侧栏名 | 取文件内 `<title>`(缺省则用文件名) |
| 预览 | 宿主 iframe 预览;右键:重新生成 / 浏览器打开 / 刷新 |
| 模板资源 | 建议放 `assets/html/*.html`(安装包内),渲染后**写出**到数据目录 `html/` |
| 预览 | 宿主 iframe 预览。壳子页右键:打开 / 打开目录 / 刷新,**没有**「重新生成」。快照右键另有「重新生成」 |
| 同名 | 壳子和快照文件名相同则留下快照。两边不要用同一个文件名 |
| 快照模板 | `assets/html/example-report.html` 是写出快照时用的壳,**不要**放进 `pages/` |
### 壳子页
1. 把自包含 HTML 放到 `assets/html/pages/`(一层,不要子目录)。`<title>` 就是侧栏名字。
2. 布局底线与下面「宿主预览布局」相同:铺满、禁横向滚动、禁外链 CSS/JS。
3. 不要在初始化数据库时把这些文件拷进 `{skill_data}/html/`。升级技能后宿主读的是新安装包里的文件。
### 页面调用
宿主在数据管理预览里注入 `jiangchang.call`。页面脚本把它当成普通函数调用;宿主只在**当前技能** `actions.json` 已声明的方法里查找,命中后走和表按钮同一条 Python CLI,把 CLI 打出的 JSON 交回。
```javascript
const data = await jiangchang.call("方法名", { date: "2026-09-19" });
```
| 项 | 约定 |
|----|------|
| 方法名 | `actions.json` 里该条的 `id`。没声明则调用失败。不能传任意命令,也不能调别的技能 |
| 参数 | 一个 JSON 对象,字段必须落在该条 `inputSchema` 里 |
| 入口 | 可以不挂 `toolbar`。页面调用不要求表按钮。`placements` 仍不能为空(例如只挂 `agent`) |
| 成功 | CLI 退出码 0,并打印**一个** JSON 对象。页面拿到的就是这个对象 |
| 失败 | 退出码非 0,JSON 里带可读的 `message`(`type` 为 `result`、`status` 为 `failed` 时宿主会把 `message` 交给页面) |
| 范围 | 只在数据管理预览里有这个函数。用浏览器单独打开该文件时没有,页面应说明「请在数据管理中打开」 |
异步命令当场只返回任务号,不在这一节展开。写入和只读是同一种调用,差在技能声明了哪条命令。
### Action:`render-html`(固定 id)
@@ -844,7 +872,7 @@ HINT: <next step>
### 宿主预览布局
数据管理在 **iframe** 内预览 `html/*.html`;宿主 iframe 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。
数据管理在 **iframe** 内预览壳子页和快照;宿主 iframe 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。
| 级别 | 约定 |
|------|------|
@@ -870,7 +898,9 @@ HINT: <next step>
### 验收(复制后自测)
- [ ] `html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常
- [ ] 壳子页:安装后侧栏能看到 `assets/html/pages/` 里的标题,无需点生成;右键没有「重新生成」
- [ ] 页面调用:`jiangchang.call` 能拿到 CLI 的 JSON;未声明的方法名失败且有可读错误
- [ ] 快照:`html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常
- [ ] 预览区**横向铺满**、**无横向滚动条**;拖窄侧栏后内容自适应(见上节「宿主预览布局」)
- [ ] 表顶栏(若声明 toolbar)或报告右键「重新生成」能跑通;成功后预览刷新
- [ ] 未登录/无权限用户被拒绝,且有可读错误

View File

@@ -1,9 +1,9 @@
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。
> **状态:正式契约 v1.0(`cells[]` 文档 `schemaVersion` 1)+ 加法 Grid Presentation。**
> **目标:** 冻结「技能声明语义呈现 → 宿主映射主题样式」的通用能力:某一格、整行、整列、若干行×若干列,供任意业务场景复用。
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)(格子)、[`../assets/schemas/skill-grid-presentation.schema.json`](../assets/schemas/skill-grid-presentation.schema.json)(行/列/区域)。
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
@@ -11,9 +11,17 @@
## 0. 一句话
技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。
技能可以声明:**哪张业务表**上,**哪些行 × 哪些列**需要特殊标色/强调(语义 `tone` + 可选说明)。
| 想涂什么 | 怎么声明 |
|----------|----------|
| 某一格 | 一行 × 一列(v1 `cells[]` 或一条格子规则) |
| 整行 | 若干行 × 该轴全部列 |
| 整列 | 该轴全部行 × 若干列 |
| 区域 | 若干行 × 若干列 |
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子或行**上。
---
@@ -22,9 +30,20 @@
| 档位 | 技能行为 | 宿主行为 |
|------|----------|----------|
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 |
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应格子 / 行 / 列 / 区域的语义样式;可选汇总 `alert: true` 做进页提醒 |
多数技能保持 **P0**。需要视觉强调时再启用。
多数技能保持 **P0**。需要视觉强调时再启用。模板 `actions.json` **默认不启用**。
---
## 0.2 宿主能力
| 能力 | 旁路表 | 宿主 |
|------|--------|------|
| 格子着色 | `_jiangchang_cell_presentation` | 既有宿主即可 |
| 整行 / 整列 / 区域 | `_jiangchang_grid_presentation` | 需已读取该表(匠厂数据管理已支持)。**旧宿主忽略该表**,格子着色仍有效 |
同一开关 `cellPresentation.enabled` 同时打开两张旁路表。未建表 = 该通道为空,宿主忽略。
---
@@ -32,17 +51,17 @@
| 角色 | 负责 | 不负责 |
|------|------|--------|
| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
| **技能** | 按**当前业务数据**算出哪些格子/行/列要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;按几何涂表格;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
---
## 2. 文档形状(冻)
## 2. 格子文档形状(冻,`schemaVersion` 1)
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合格子 Schema。v1 **只描述某一格**;整行/整列用 §2.1。
```json
{
@@ -71,14 +90,14 @@
| 字段 | 必填 | 说明 |
|------|------|------|
| `schemaVersion` | 是 | 固定 `1` |
| `schemaVersion` | 是 | 固定 `1`(本形状不升版本) |
| `skill` | 是 | 与 slug 一致 |
| `table` | 是 | 当前文档作用的业务表 |
| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) |
| `generatedAt` | 否 | 排障用 |
| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** |
| `cells` | 是 | 数组;**空数组 = 该表清除全部格子呈现** |
### 2.1 `cells[]` 项
### 2.0 `cells[]` 项
| 字段 | 必填 | 说明 |
|------|------|------|
@@ -91,6 +110,74 @@
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
### 2.1 Grid 文档形状(加法,独立 Schema)
整行 / 整列 / 区域用 **Grid Presentation Document**(`rules[]`)。与 `cells[]` **并存**,不替代、不升 `cells` 的 `schemaVersion`。
统一几何:**`rows` × `columns`**。某一侧为 `"all"`(或省略,落库时写成 JSON `"all"`)= 该轴全部。禁止两侧都是全部(见 §4.2)。
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"generatedAt": "2026-08-10T06:00:00Z",
"rules": [
{
"ruleKey": "qty-low-rows",
"rows": ["42", "99"],
"columns": "all",
"tone": "danger",
"message": "库存低于安全线",
"alert": true
},
{
"ruleKey": "status-column",
"rows": "all",
"columns": ["status"],
"tone": "warning",
"message": "待审核"
},
{
"ruleKey": "overdue-print-region",
"rows": ["7", "8"],
"columns": ["print_done_at", "ship_done_at"],
"tone": "danger",
"message": "环节到期未填",
"alert": true
}
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `schemaVersion` | 是 | 固定 `1` |
| `skill` / `table` / `rowKey` | 是 | 同格子文档;`rowKey` 用于匹配 `rows` 里的 id |
| `rules` | 是 | 数组;**空数组 = 该表清除全部 grid 规则** |
#### `rules[]` 项
| 字段 | 必填 | 说明 |
|------|------|------|
| `ruleKey` | 是 | 本表内唯一(建议稳定业务键,便于重算覆盖) |
| `rows` | 是 | `"all"` 或行 id 字符串数组(对应 `rowKey` 列值) |
| `columns` | 是 | `"all"` 或业务列名数组 |
| `tone` | 是 | 见 §3 |
| `message` | 否 | ≤500 |
| `alert` | 否 | 默认 `false`;按**规则条数**计提醒,不按命中格子数 |
| `rows` × `columns` | 宿主 scope |
|--------------------|-----------|
| 1 行 × 1 列 | `cell` |
| 若干行 × 若干列(非单格) | `region` |
| 若干行 × `"all"` | `row`(整行) |
| `"all"` × 若干列 | `column`(整列) |
| `"all"` × `"all"` | **禁止**,宿主丢弃该规则 |
完整 JSON 例见 [`../assets/examples/skill-grid-presentation.example.json`](../assets/examples/skill-grid-presentation.example.json)。
---
## 3. `tone` 闭集(冻)
@@ -106,13 +193,13 @@
**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。
以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格/行/列 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
---
## 4. 推荐持久化(给宿主实现)
v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
v1 **推荐**技能将「当前表」的**格子**呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
```sql
CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
@@ -130,19 +217,60 @@ CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
);
```
### 4.1 整行 / 整列 / 区域(`_jiangchang_grid_presentation`)
```sql
CREATE TABLE IF NOT EXISTS _jiangchang_grid_presentation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
rule_key TEXT NOT NULL,
row_key TEXT NOT NULL DEFAULT '',
rows_json TEXT NOT NULL,
columns_json TEXT NOT NULL,
tone TEXT NOT NULL,
message TEXT,
alert INTEGER NOT NULL DEFAULT 0,
generated_at TEXT,
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE (table_name, rule_key)
);
```
`rows_json` / `columns_json` 存 JSON:字符串 `"all"`,或 id / 列名数组(如 `["42","99"]`)。
`row_key`:有限行集合时必填(与文档 `rowKey` 相同,如 `id`);整列(`rows_json = "all"`)可空字符串。
两张表都列入技能 `INTERNAL_METADATA_TABLES`(见 `scripts/db/display_metadata.py`),**不得**作为业务表出现在数据管理侧栏。模板 **不**在 `init_db()` 里默认建这两张表;技能用到时再 `CREATE`。
### 4.2 冲突与特异性
同一格被多条规则命中时,宿主按 **格子 > 区域 > 行 > 列** 取胜。同级后者覆盖前者。
宿主先加载 grid 规则,再加载 v1 `cells[]`(同级时 **v1 格子仍赢**),因此旧技能只写 `cells[]` 的行为不变。
**禁止 `all` × `all`**(会把整张表涂满)。技能不要写;宿主遇到则丢弃该规则。
### 4.3 表格 vs 详情
| 表面 | 整行(scope=`row`) | 格子 / 列 / 区域 |
|------|-------------------|------------------|
| 数据管理表格 | 给整行(`<tr>`)上色 | 给对应单元格上色 |
| 行详情 | **不**把行色铺到每个字段 | 对应字段可保留 tone |
整行强调的是「这一行在表里扎眼」,不是「打开详情后每个值都变色」。
约定:
- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。
- 重算某表时:**先删该 `table_name` 在两张旁路表中的全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。只维护其中一张时,至少清掉自己负责的那张,并删掉已迁走的旧行(例如从格子改成整行后,删掉对应 `_jiangchang_cell_presentation`)。
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在旁路表,则按当前页行的 `rowKey` 值匹配并着色。
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
未创建该旁路表 = P0,宿主忽略。
未创建该旁路表 = 该通道 P0,宿主忽略。
---
## 5. 在 `actions.json` 中启用(可选,推荐)
与 `policy` 类似,顶层可选:
与 `policy` 类似,顶层可选。**一份开关同时启用格子与 grid:**
```json
{
@@ -158,7 +286,7 @@ CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
| 字段 | 说明 |
|------|------|
| `cellPresentation` | 省略 = P0 |
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 |
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现(两张旁路表);`false`/缺省等同 P0 |
v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。
@@ -172,8 +300,8 @@ v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync`
2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等)
3. (可选)用户显式「刷新呈现」类 Action
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。
提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的**规则条数**(grid 按规则计,不按命中格子数);**0 则不打扰**。
提醒关闭 = 重算后没有对应 cell/rule,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
---
@@ -181,7 +309,7 @@ v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync`
| 契约 | 关系 |
|------|------|
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 |
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色。呈现旁路表须进内部隐藏表集合 |
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
@@ -195,16 +323,21 @@ v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync`
- 只在首次超时时打标、填完不重算导致红字残留
- 宿主写死某个技能 slug 的特殊染色逻辑
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
- 写 `rows: "all"` 且 `columns: "all"` 企图整表变色
- 假定打开行详情后,整行色会铺到每一个字段
- 把某一业务技能的检查逻辑(例如「检查列 → 整行」)复制进模板当通用运行时
---
## 9. 示例场景(说明用途不限)
| 场景 | column | tone | message 例 |
|------|--------|------|------------|
| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 |
| 库存低于安全线 | `qty` | `warning` | 低于安全库存 |
| 审核驳回 | `status` | `danger` | 已驳回,请修改 |
| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 |
| 场景 | 几何 | tone | message 例 |
|------|------|------|------------|
| 打单超时未填 | 格子 `print_done_at` | `danger` | 打单已超 1 天未填 |
| 库存低于安全线 | **整行**(该行 × `all`) | `warning` | 低于安全库存 |
| 审核驳回 | 格子或整列 `status` | `danger` | 已驳回,请修改 |
| 选填备注缺失但不紧急 | 格子 `notes` | `muted` | 建议补充备注 |
| 两个日期列同时逾期 | 区域:若干行 × `print_done_at`+`ship_done_at` | `danger` | 环节到期未填 |
完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。
格子 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。
Grid JSON 例见 [`../assets/examples/skill-grid-presentation.example.json`](../assets/examples/skill-grid-presentation.example.json)。

View File

@@ -27,7 +27,7 @@ python {baseDir}/scripts/main.py <command> …
| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` |
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。快照「重新生成」须有 Action id **`render-html`**;壳子页与页面调用 `jiangchang.call` 见 ACTIONS.md「HTML 页面报告」。
## 最小命令

View File

@@ -20,8 +20,8 @@
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) |
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) |
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理呈现:格子 / 整行 / 整列 / 区域语义 tone(用途不限;多数技能 P0 不配) |
| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) |
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。

View File

@@ -17,7 +17,7 @@
- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里)
- 定义哪些字段可见、可搜索、可创建、可编辑
- 定义字段展示类型和枚举选项
- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
- (可选)按业务规则维护单元格 / 整行 / 整列 / 区域呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
- 保证元数据与真实数据库结构一致
- 保证元数据初始化幂等
@@ -36,7 +36,7 @@
- 不猜测技能业务含义
- 不根据具体技能名称写特殊逻辑
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为格子 / 行 / 列 / 区域映射语义样式(`tone`);不猜测业务规则;整行色只涂表格行,不铺到详情字段
## 初始化入口
@@ -45,7 +45,8 @@
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`;SQL 文件里的 `-- 注释` 也不会成为可查询结构。
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info` 的 `cid`)。
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
> 可选呈现旁路表 `_jiangchang_cell_presentation` / `_jiangchang_grid_presentation` **不是**展示元数据,但须列入 `INTERNAL_METADATA_TABLES`(侧栏不可见)。见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。
## 技能名称(SKILL.md)
@@ -143,7 +144,7 @@ metadata:
| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` |
| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) |
| **HTML 页面报告** | 侧栏可预览的整页 HTML | 壳子页 `assets/html/pages/`;快照写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) |
同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。
@@ -179,11 +180,36 @@ JSON 数组(可多字段,按顺序):
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `column` 必须是该表**真实物理列名**(存在于 SQLite 表);不存在则宿主忽略该段或技能校验报错
- **可以**引用 `visible=0` 的列(含隐藏的主键 `id`)。`visible=0` 只表示界面不展示 / 不进默认 SELECT·筛选·搜索;**不表示不能 ORDER BY**
- 敏感列(宿主标为 sensitive)不得用于排序;技能侧勿把密钥类字段写进 `default_sort_json`
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
与 `visible` 的分工(勿再踩坑):
| 能力 | `visible=0` 是否仍可用 |
|------|------------------------|
| 表格列展示 / 「列」面板 | 否(硬隐藏) |
| 默认 SELECT、列筛选、顶栏搜索 | 否 |
| **`default_sort_json` / ORDER BY** | **是**(非敏感列) |
| 行主键定位(`$row.$pk` 等) | 是(主键即使隐藏仍应可排序、可定位;宿主查询层保留) |
示例(编号硬隐藏,但仍按编号排序——合法):
```python
upsert_table_metadata(
cursor,
table_name="period_rule_overrides",
display_name="本月默认金额",
readonly=1,
default_sort_json='[{"column":"period","direction":"desc"},{"column":"id","direction":"asc"}]',
)
# id 列元数据可见性:
# upsert_column_metadata(..., column_name="id", visible=0, ...)
```
示例(消息时间线):
```python
@@ -276,6 +302,8 @@ upsert_column_metadata(
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
- 误以为「列 `visible=0` 就不能写进 `default_sort_json`」——排序与展示已拆开;隐藏编号/主键仍可排序(须列真实存在且非敏感)
- 把不存在的列名写进 `default_sort_json`(技能校验应报错;宿主也会忽略无效段)
### `_jiangchang_columns` 字段级标准

View File

@@ -9,7 +9,15 @@ from __future__ import annotations
from typing import Iterable
# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts)
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
# 呈现旁路表由技能按需 CREATE,见 references/CELL_PRESENTATION.md;未建表不影响 P0。
INTERNAL_METADATA_TABLES = frozenset(
{
"_jiangchang_tables",
"_jiangchang_columns",
"_jiangchang_cell_presentation",
"_jiangchang_grid_presentation",
}
)
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
@@ -95,6 +103,7 @@ def upsert_table_metadata(
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
为空则宿主回退到 updated_at → created_at → 主键。
排序列须为真实物理列;**允许**引用 visible=0 的非敏感列(隐藏≠不能 ORDER BY)。
default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如
'["stat_type","period","stage"]'

View File

@@ -279,6 +279,95 @@ def validate_filter_search_metadata(
return [], warnings
def parse_default_sort_json(raw: object) -> tuple[list[dict[str, str]], list[str]]:
"""解析 default_sort_json;返回 (specs, errors)。specs 项含 column / direction。"""
errors: list[str] = []
if raw is None or str(raw).strip() == "":
return [], []
text = str(raw).strip()
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return [], ["default_sort_json 不是合法 JSON"]
if not isinstance(parsed, list):
return [], ["default_sort_json 必须是 JSON 数组"]
out: list[dict[str, str]] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
errors.append(f"default_sort_json[{index}] 必须是对象")
continue
column = str(
item.get("column")
or item.get("columnName")
or item.get("column_name")
or ""
).strip()
direction_raw = str(item.get("direction") or "desc").strip().lower()
if not column:
errors.append(f"default_sort_json[{index}] 缺少 column")
continue
if direction_raw not in {"asc", "desc"}:
errors.append(f"default_sort_json[{index}].direction 只能是 asc 或 desc")
continue
out.append({"column": column, "direction": direction_raw})
return out, errors
def validate_default_sort_metadata(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""校验表级 default_sort_json:列须真实存在;允许 visible=0;不存在则报错。
契约:visible=0 只藏界面,ORDER BY 仍可用非敏感物理列(含隐藏主键)。
"""
errors: list[str] = []
warnings: list[str] = []
table_cols = {
str(row[1]) for row in cursor.execute("PRAGMA table_info(_jiangchang_tables)").fetchall()
}
if "default_sort_json" not in table_cols:
return [], []
cursor.execute(
"SELECT default_sort_json FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cursor.fetchone()
if not row:
return [], []
specs, parse_errors = parse_default_sort_json(row[0])
for msg in parse_errors:
errors.append(f"{table_name}: {msg}")
if parse_errors:
return errors, warnings
physical = set(physical_columns or list_physical_column_names(cursor, table_name))
for spec in specs:
column = spec["column"]
if column not in physical:
errors.append(
f"{table_name}: default_sort_json 引用了不存在的列 {column!r}"
)
continue
# visible=0 合法:不报错。仅提示契约,避免作者误删排序列。
cursor.execute(
"""
SELECT visible FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column),
)
meta = cursor.fetchone()
if meta is not None and meta[0] is not None and int(meta[0]) == 0:
warnings.append(
f"{table_name}.{column}: default_sort_json 引用了 visible=0 列"
"(合法:隐藏列仍可 ORDER BY;见 references/SCHEMA.md)"
)
return errors, warnings
def validate_column_permissions(
cursor,
table_name: str,
@@ -558,6 +647,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
_, filter_warnings = validate_filter_search_metadata(cur, table_name)
report.warnings.extend(filter_warnings)
sort_errors, sort_warnings = validate_default_sort_metadata(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(sort_errors)
report.warnings.extend(sort_warnings)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:

View File

@@ -1,9 +1,10 @@
"""技能 HTML 报告页:写入 `{skill_data}/html/*.html`(宿主扫描预览)。
"""技能 HTML 快照:写入 `{skill_data}/html/*.html`(宿主扫描预览)。
契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」):
- 目录:仅一层 `html/*.html`(非递归)
- 重新生成 Action id 必须为 `render-html`(宿主硬编码)
- 本模块只写快照。装完就有的壳子页放安装包 `assets/html/pages/*.html`,不要从这里拷过去
- 快照目录:仅一层 `html/*.html`(非递归)
- 重新生成 Action id 必须为 `render-html`(宿主硬编码;只针对快照)
- 页面布局底线见 `references/ACTIONS.md`「宿主预览布局」;模板壳 `assets/html/example-report.html`
- 成功后建议 `steps: refreshCatalog` 以重扫侧栏
- 本模块只提供路径/模板/原子写;业务 KPI/表格由各技能自行填充

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.108"
SKILL_VERSION = "1.0.114"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

View File

@@ -134,7 +134,7 @@ def get_videos_dir(*, create: bool = True) -> str:
def get_html_dir(*, create: bool = True) -> str:
"""HTML 报告页目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。"""
"""HTML 快照目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。壳子页不在这里。"""
path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_HTML)
if create:
os.makedirs(path, exist_ok=True)

View File

@@ -7,7 +7,7 @@ import re
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_MAX_SLUG_LENGTH = 48
_MIN_SEGMENTS = 3
_MIN_SEGMENTS = 2
_MAX_SEGMENTS = 5
_MAX_NOUN_SEGMENTS = 3
@@ -60,7 +60,9 @@ _BUILTIN_PLATFORMS = frozenset(
"etax",
}
)
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"})
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi"})
# 旧技能用 default 占位末段。不再当成合法 scope;校验只警告,避免同步后挡住已发布仓库。
_DEPRECATED_PLATFORM_PLACEHOLDER = "default"
LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset(
{
@@ -117,11 +119,12 @@ def _match_platform_suffix(
def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
"""
返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid'
返回: 'legacy_exempt' | 'standard' | 'scope' | 'plain' | 'invalid'
- legacy_exempt: slug in LEGACY_EXEMPT_SLUGS
- standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5
- scope: 末段 in scopes, 首段 in verbs, 3<=len<=5
- standard: 末段匹配 platforms,首段 in verbs,2<=len<=5,且动词后至少还有一段名词
- scope: 末段 in scopes(all/batch/multi),首段 in verbs,至少 verb-noun-scope
- plain: 动词 + 名词,无平台、无 scope
- invalid: 其他
"""
if slug in LEGACY_EXEMPT_SLUGS:
@@ -138,12 +141,14 @@ def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
return "invalid"
if segments[-1] in scopes:
if len(segments) < 3:
return "invalid"
return "scope"
if _match_platform_suffix(segments, platforms) is not None:
return "standard"
return "invalid"
return "plain"
def validate_slug_semantics(
@@ -173,8 +178,8 @@ def validate_slug_semantics(
if segment_count < _MIN_SEGMENTS:
msg = (
f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(verb-noun-platform),"
f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance)"
f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(动词-名词,平台可选),"
f"当前为 {segment_count} 段:{slug!r}"
)
(errors if strict else warnings).append(msg)
return errors, warnings
@@ -197,9 +202,6 @@ def validate_slug_semantics(
form = classify_slug_form(segments, slug=slug)
if form == "scope":
if segments[-1] not in scopes:
msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}(slug={slug!r})"
(errors if strict else warnings).append(msg)
noun_count = segment_count - 2
elif form == "standard":
platform_match = _match_platform_suffix(segments, platforms)
@@ -210,13 +212,17 @@ def validate_slug_semantics(
else:
_platform_name, platform_part_count = platform_match
noun_count = segment_count - 1 - platform_part_count
elif form == "plain":
noun_count = segment_count - 1
if segments[-1] == _DEPRECATED_PLATFORM_PLACEHOLDER:
warnings.append(
"slug 末段 default 已不再作为平台占位;没有外部平台时直接省略。"
f"已发布的技能不用改名:{slug!r}"
)
else:
if segment_count >= _MIN_SEGMENTS:
if segments[-1] not in scopes:
platform_match = _match_platform_suffix(segments, platforms)
if platform_match is None:
msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}"
(errors if strict else warnings).append(msg)
if segments and segments[-1] in scopes and segment_count < 3:
msg = f"scope 型 slug 至少为动词-名词-scope:{slug!r}"
(errors if strict else warnings).append(msg)
noun_count = 0
if noun_count > _MAX_NOUN_SEGMENTS:

668
sync.ps1 Normal file
View File

@@ -0,0 +1,668 @@
<#
.SYNOPSIS
Sync this skill's plaintext source into the local OpenClaw skills directory.
.DESCRIPTION
Local development only. Does not commit, tag, push, or trigger Gitea CI.
release.ps1 remains the formal publish path.
Ships in skill-template beside release.ps1. Scaffold copies this file into each new skill.
Mirrors this repo onto ~/.openclaw/skills/<slug> and removes marketplace
pyarmor_runtime_* leftovers. Stop any running job for this skill first;
otherwise the copy can fail because files are in use.
Dependencies follow the host skill:direct-install path:
uv pip install --python <JIANGCHANG_DATA_ROOT>/python-runtime/.venv/Scripts/python.exe -r requirements.txt
Packages are not installed into system Python. Unchanged requirements that
are already present in the shared venv are skipped.
platform_kit_min_version is provided by jiangchang-platform-kit in the shared
runtime. This script only checks it. It does not upgrade the kit. If the
installed kit is too old, install or update the skill once from the market,
or upgrade the host.
After the copy, this script runs scripts\main.py init-db the same way the
host does after a market install. The user id is read from
%APPDATA%\jiangchang\settings.json (jiangchangUserId), which the host writes
on login and logout. It is never hardcoded. An empty id or _anon stops the
script so the anonymous data folder is not initialized by mistake.
-SkipInitDb skips that step. -SkipDeps still runs init-db.
No Chinese literals in this file: the skill repo requires UTF-8 without BOM,
and Windows PowerShell 5.1 would mis-parse a no-BOM UTF-8 script.
.EXAMPLE
.\sync.ps1
.\sync.ps1 -DryRun
.\sync.ps1 -ForceDeps
.\sync.ps1 -SkipDeps
.\sync.ps1 -SkipInitDb
.\sync.ps1 -DataRoot D:\jiangchang-data
#>
[CmdletBinding()]
param(
[string]$Destination,
[string]$DataRoot,
[switch]$SkipDeps,
[switch]$ForceDeps,
[switch]$SkipInitDb,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$SourceRoot = $PSScriptRoot
$PypiMirrors = @(
@{ Name = "aliyun"; Url = "https://mirrors.aliyun.com/pypi/simple/" },
@{ Name = "tuna"; Url = "https://pypi.tuna.tsinghua.edu.cn/simple/" },
@{ Name = "tencent"; Url = "https://mirrors.cloud.tencent.com/pypi/simple/" },
@{ Name = "huawei"; Url = "https://repo.huaweicloud.com/repository/pypi/simple/" },
@{ Name = "pypi"; Url = "https://pypi.org/simple/" }
)
function Read-Utf8Text {
param([Parameter(Mandatory = $true)][string]$Path)
return [System.IO.File]::ReadAllText($Path)
}
function Get-SkillFrontmatter {
param([Parameter(Mandatory = $true)][string]$SkillMdPath)
$raw = Read-Utf8Text -Path $SkillMdPath
if ($raw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---') {
throw "SKILL.md is missing frontmatter: $SkillMdPath"
}
return $Matches[1]
}
function Get-FrontmatterScalar {
param(
[Parameter(Mandatory = $true)][string]$Body,
[Parameter(Mandatory = $true)][string]$Key
)
$pattern = '(?m)^\s*' + [regex]::Escape($Key) + ':\s*["'']?([^\s"''#]+)["'']?\s*$'
$match = [regex]::Match($Body, $pattern)
if (-not $match.Success) {
return $null
}
return $match.Groups[1].Value.Trim()
}
function Get-RequirementsLines {
param([Parameter(Mandatory = $true)][string]$Content)
$lines = New-Object System.Collections.Generic.List[string]
foreach ($raw in ($Content -split '\r?\n')) {
$trimmed = $raw.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#')) {
continue
}
$withoutInline = ($trimmed -split '\s+#', 2)[0].Trim()
if ($withoutInline) {
$lines.Add($withoutInline)
}
}
return @($lines)
}
function Get-RequirementsHash {
param([Parameter(Mandatory = $true)][string]$Content)
$normalized = (@(Get-RequirementsLines -Content $Content)) -join "`n"
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
$hash = $sha.ComputeHash($bytes)
} finally {
$sha.Dispose()
}
return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant()
}
function Get-DistributionNames {
param([Parameter(Mandatory = $true)][string[]]$Lines)
$names = New-Object System.Collections.Generic.List[string]
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
foreach ($line in $Lines) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith('-')) {
continue
}
if ($trimmed -match '^(https?:|git\+|svn\+|hg\+|file:)') {
continue
}
$match = [regex]::Match($trimmed, '^([A-Za-z0-9][A-Za-z0-9._-]*)')
if (-not $match.Success) {
continue
}
$name = $match.Groups[1].Value
$key = $name.ToLowerInvariant().Replace('_', '-')
if ($seen.Add($key)) {
$names.Add($name)
}
}
return @($names)
}
function Resolve-SkillDataRoot {
param([string]$Explicit)
if ($Explicit -and $Explicit.Trim()) {
return $Explicit.Trim().TrimEnd('\')
}
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
if (Test-Path -LiteralPath $settingsPath) {
try {
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
$fromSettings = [string]$settings.jiangchangSkillDataRoot
if ($fromSettings -and $fromSettings.Trim()) {
return $fromSettings.Trim().TrimEnd('\')
}
} catch {
Write-Warning "Could not read $settingsPath. Falling back to the default data root. $($_.Exception.Message)"
}
}
foreach ($candidate in @("D:\jiangchang-data", (Join-Path $env:USERPROFILE ".jiangchang-data"))) {
$python = Join-Path $candidate "python-runtime\.venv\Scripts\python.exe"
if (Test-Path -LiteralPath $python) {
return $candidate
}
}
return "D:\jiangchang-data"
}
function Resolve-JiangchangUserId {
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
if (-not (Test-Path -LiteralPath $settingsPath)) {
throw "Jiangchang settings were not found: $settingsPath`nOpen Jiangchang and log in, then run this script again."
}
try {
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
} catch {
throw "Could not read the logged-in user from $settingsPath. $($_.Exception.Message)"
}
$userId = ""
if ($null -ne $settings -and $settings.PSObject.Properties.Name -contains "jiangchangUserId" -and $null -ne $settings.jiangchangUserId) {
$userId = ([string]$settings.jiangchangUserId).Trim()
}
if (-not $userId -or $userId -eq "_anon") {
throw "Jiangchang has no logged-in user. Open Jiangchang and log in, then run this script again. This script does not guess a user id."
}
$normalized = $userId.Replace("\", "/")
if ($normalized.Contains("..") -or $normalized.Contains("/") -or $userId.StartsWith("\") -or $userId -match '^[A-Za-z]:') {
throw "jiangchangUserId is not a single folder name: $userId"
}
return $userId
}
function Test-InitDbJsonOk {
param([Parameter(Mandatory = $true)][string]$Text)
foreach ($line in ($Text -split "`r?`n")) {
$trimmed = $line.Trim()
if (-not $trimmed.StartsWith("{")) {
continue
}
try {
$parsed = $trimmed | ConvertFrom-Json
if ($parsed.ok -eq $true) {
return $true
}
} catch {
continue
}
}
return $false
}
function Test-InitDbUnknownCommand {
param(
[Parameter(Mandatory = $true)][string]$Text,
[Parameter(Mandatory = $true)][int]$ExitCode
)
if ($ExitCode -eq 2) {
return $true
}
$lower = $Text.ToLowerInvariant()
if ($lower.Contains("invalid choice") -or $lower.Contains("unrecognized arguments") -or $lower.Contains("unknown command")) {
return $true
}
if ($lower.Contains("invalid") -and $lower.Contains("init-db")) {
return $true
}
return $false
}
function Invoke-SkillInitDb {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$SkillDir,
[Parameter(Mandatory = $true)][string]$DataRoot,
[Parameter(Mandatory = $true)][string]$UserId,
[switch]$DryRun
)
$mainPy = Join-Path $SkillDir "scripts\main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
Write-Host "No scripts\main.py. Skipping init-db."
return
}
if ($DryRun) {
Write-Host "DryRun: would run init-db" -ForegroundColor Yellow
Write-Host " python $PythonExe"
Write-Host " script $mainPy init-db"
Write-Host " data $DataRoot"
Write-Host " user $UserId"
return
}
if (-not (Test-Path -LiteralPath $PythonExe)) {
throw "Shared Python was not found: $PythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot."
}
Write-Host ">> Initialize the logged-in user's database (init-db)" -ForegroundColor DarkGray
Write-Host " user=$UserId"
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $PythonExe
$psi.WorkingDirectory = $SkillDir
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.Arguments = '"' + $mainPy + '" init-db'
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
$psi.EnvironmentVariables["JIANGCHANG_DATA_ROOT"] = $DataRoot
$psi.EnvironmentVariables["JIANGCHANG_USER_ID"] = $UserId
$psi.EnvironmentVariables["JIANGCHANG_RUN_MODE"] = "cli"
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit(60000)) {
try { $proc.Kill() } catch { }
throw "init-db timed out after 60 seconds."
}
$proc.WaitForExit()
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
$combined = ($stdout + "`n" + $stderr).Trim()
if ($stdout.Trim()) {
Write-Host $stdout.Trim()
}
if ($stderr.Trim()) {
Write-Host $stderr.Trim()
}
if (Test-InitDbJsonOk -Text $combined) {
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
return
}
if ($proc.ExitCode -eq 0 -and $combined -match '(?m)^OK:\s*INIT_DB\b') {
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
return
}
if (Test-InitDbUnknownCommand -Text $combined -ExitCode $proc.ExitCode) {
Write-Warning "This skill has no init-db command. Database initialization was skipped."
return
}
$detail = $stderr.Trim()
if (-not $detail) {
$detail = $stdout.Trim()
}
if ($detail.Length -gt 500) {
$detail = $detail.Substring($detail.Length - 500)
}
if (-not $detail) {
$detail = "init-db exited with code $($proc.ExitCode)"
}
throw "init-db failed for user ${UserId}: $detail"
}
function Resolve-UvExecutable {
$cmd = Get-Command uv -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source) {
return $cmd.Source
}
throw "uv was not found on PATH. The host installs skill requirements with uv pip, not pip."
}
function Test-DistributionsPresent {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string[]]$Names
)
if ($Names.Count -eq 0) {
return $false
}
$probe = @'
from importlib.metadata import PackageNotFoundError, version
import sys
missing = []
for n in sys.argv[1:]:
try:
version(n)
except PackageNotFoundError:
missing.append(n)
sys.stderr.write("missing:" + ",".join(missing) if missing else "")
raise SystemExit(1 if missing else 0)
'@
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $PythonExe -c $probe @Names 2>&1 | Out-Null
return ($LASTEXITCODE -eq 0)
} finally {
$ErrorActionPreference = $prev
}
}
function Get-InstalledDistributionVersion {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$Name
)
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $PythonExe -c "from importlib.metadata import version, PackageNotFoundError`nimport sys`nname=sys.argv[1]`ntry:`n print(version(name))`nexcept PackageNotFoundError:`n print('')" $Name 2>$null
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $prev
}
if ($code -ne 0) {
return $null
}
return ([string]$output).Trim()
}
function Compare-VersionPrefix {
param(
[Parameter(Mandatory = $true)][string]$Installed,
[Parameter(Mandatory = $true)][string]$Required
)
function Convert-ToVersion([string]$Text) {
$core = ($Text -split '[^0-9.]', 2)[0].Trim('.')
$parts = @($core -split '\.' | Where-Object { $_ -ne '' })
while ($parts.Count -lt 3) {
$parts += '0'
}
$take = @($parts | Select-Object -First 3)
return [version]($take -join '.')
}
try {
return (Convert-ToVersion $Installed) -ge (Convert-ToVersion $Required)
} catch {
return $true
}
}
function Invoke-RobocopyMirror {
param(
[Parameter(Mandatory = $true)][string]$Source,
[Parameter(Mandatory = $true)][string]$Dest,
[switch]$ListOnly
)
$excludeDirs = @(
".git", ".github", "__pycache__", ".pytest_cache", "htmlcov",
"dist", "build", ".venv", "_fix_workspace", ".skill-requirements"
)
$excludeFiles = @(".env", "*.env.local", "*.pyc", "*.pyo")
$robocopyArgs = @($Source, $Dest, "/MIR", "/R:2", "/W:1", "/XD") + $excludeDirs + @("/XF") + $excludeFiles
if ($ListOnly) {
$robocopyArgs += "/L"
}
& robocopy.exe @robocopyArgs
$code = $LASTEXITCODE
if ($code -ge 8) {
throw "Copy failed (robocopy exit $code). Stop the running skill job in Task Center, then retry."
}
}
function Remove-PyarmorRuntimes {
param([Parameter(Mandatory = $true)][string]$Dest)
$removed = @()
$dirs = @(Get-ChildItem -LiteralPath $Dest -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "pyarmor_runtime_*" })
foreach ($dir in $dirs) {
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
$removed += $dir.FullName
}
return $removed
}
function Assert-PlainMainPy {
param([Parameter(Mandatory = $true)][string]$MainPy)
if (-not (Test-Path -LiteralPath $MainPy)) {
throw "scripts\main.py is missing after sync: $MainPy"
}
$head = Read-Utf8Text -Path $MainPy
if ($head -match 'pyarmor_runtime_|__pyarmor__') {
throw "scripts\main.py is still PyArmor-encrypted. Re-run .\sync.ps1 so the whole scripts tree is replaced."
}
}
function Write-RequirementsState {
param(
[Parameter(Mandatory = $true)][string]$RuntimeDir,
[Parameter(Mandatory = $true)][string]$Slug,
[Parameter(Mandatory = $true)][string]$RequirementsPath,
[Parameter(Mandatory = $true)][string]$RequirementsHash,
[string]$RuntimeFingerprint
)
$stateDir = Join-Path $RuntimeDir ".skill-requirements"
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
$state = [ordered]@{
skill = $Slug
requirementsPath = $RequirementsPath
requirementsHash = $RequirementsHash
runtimeFingerprint = $RuntimeFingerprint
installedAt = [DateTime]::UtcNow.ToString("o")
status = "ok"
}
$json = ($state | ConvertTo-Json -Depth 4) + "`n"
$statePath = Join-Path $stateDir "$Slug.json"
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($statePath, $json, $utf8)
}
$skillMd = Join-Path $SourceRoot "SKILL.md"
$sourceMain = Join-Path $SourceRoot "scripts\main.py"
$sourceReq = Join-Path $SourceRoot "requirements.txt"
if (-not (Test-Path -LiteralPath $skillMd)) {
throw "SKILL.md not found: $skillMd"
}
if (-not (Test-Path -LiteralPath $sourceMain)) {
throw "scripts\main.py not found: $sourceMain"
}
$frontmatter = Get-SkillFrontmatter -SkillMdPath $skillMd
$slug = Get-FrontmatterScalar -Body $frontmatter -Key "slug"
$version = Get-FrontmatterScalar -Body $frontmatter -Key "version"
$platformKitMin = Get-FrontmatterScalar -Body $frontmatter -Key "platform_kit_min_version"
if (-not $slug) {
throw "metadata.openclaw.slug in SKILL.md is empty, so the install directory cannot be determined."
}
if (-not $Destination -or -not $Destination.Trim()) {
$Destination = Join-Path $env:USERPROFILE ".openclaw\skills\$slug"
}
$Destination = $Destination.Trim().TrimEnd('\')
$sourceFull = [System.IO.Path]::GetFullPath($SourceRoot)
$destFull = [System.IO.Path]::GetFullPath($Destination)
if ($sourceFull.TrimEnd('\') -eq $destFull.TrimEnd('\')) {
throw "Source and destination are the same directory: $sourceFull"
}
$resolvedDataRoot = Resolve-SkillDataRoot -Explicit $DataRoot
$runtimeDir = Join-Path $resolvedDataRoot "python-runtime"
$pythonExe = Join-Path $runtimeDir ".venv\Scripts\python.exe"
$resolvedUserId = $null
if (-not $SkipInitDb) {
$resolvedUserId = Resolve-JiangchangUserId
}
Write-Host ""
Write-Host "Local skill sync (does not publish to the skill market)" -ForegroundColor Cyan
Write-Host " source $sourceFull"
Write-Host " dest $destFull"
Write-Host " slug $slug"
Write-Host " version $(if ($version) { $version } else { '(none)' })"
Write-Host " data $resolvedDataRoot"
Write-Host " python $pythonExe"
Write-Host " user $(if ($SkipInitDb) { '(init-db skipped)' } else { $resolvedUserId })"
if ($DryRun) {
Write-Host " mode DryRun (no writes)" -ForegroundColor Yellow
}
Write-Host ""
if (-not (Test-Path -LiteralPath $destFull)) {
Write-Warning "Destination does not exist yet. Files can be created, but if this skill was never installed from the market the host may not have enabled it. Install it once, then use this script to overwrite."
}
$configPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json"
if (Test-Path -LiteralPath $configPath) {
try {
$config = Read-Utf8Text -Path $configPath | ConvertFrom-Json
$entry = $null
if ($config.PSObject.Properties.Name -contains "skills" -and $null -ne $config.skills -and $config.skills.PSObject.Properties.Name -contains "entries" -and $null -ne $config.skills.entries) {
$entryProp = $config.skills.entries.PSObject.Properties[$slug]
if ($entryProp) {
$entry = $entryProp.Value
}
}
if ($null -eq $entry) {
Write-Warning "openclaw.json has no skills.entries.$slug. Install the skill once from the market, or chat and data management may not see it."
} elseif ($entry.PSObject.Properties.Name -contains "enabled" -and $entry.enabled -eq $false) {
Write-Warning "skills.entries.$slug is disabled. Syncing files does not re-enable it."
}
} catch {
Write-Warning "Could not check openclaw.json enablement: $($_.Exception.Message)"
}
}
Write-Host ">> Mirror source onto the install directory (exclude .git, drop encrypted runtime)" -ForegroundColor DarkGray
Invoke-RobocopyMirror -Source $sourceFull -Dest $destFull -ListOnly:$DryRun
if (-not $DryRun) {
$removed = @(Remove-PyarmorRuntimes -Dest $destFull)
foreach ($path in $removed) {
Write-Host " removed $path"
}
Assert-PlainMainPy -MainPy (Join-Path $destFull "scripts\main.py")
Write-Host "scripts\main.py is plaintext." -ForegroundColor Green
} else {
Write-Host "DryRun: pyarmor_runtime_* was not deleted, and no destination file was changed." -ForegroundColor Yellow
}
$skipInstall = $false
if ($SkipDeps) {
Write-Host "Skipped dependency install (-SkipDeps)." -ForegroundColor Yellow
$skipInstall = $true
} elseif (-not (Test-Path -LiteralPath $pythonExe)) {
throw "Shared Python was not found: $pythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot. If the copy already finished, install dependencies before running the skill."
} else {
$reqPath = if ($DryRun) { $sourceReq } else { Join-Path $destFull "requirements.txt" }
if (-not (Test-Path -LiteralPath $reqPath)) {
Write-Host "No requirements.txt. Skipping dependency install."
$skipInstall = $true
} else {
$reqContent = Read-Utf8Text -Path $reqPath
$reqLines = @(Get-RequirementsLines -Content $reqContent)
if ($reqLines.Count -eq 0) {
Write-Host "requirements.txt has no effective lines. Skipping install."
$skipInstall = $true
}
}
}
if (-not $skipInstall) {
$reqHash = Get-RequirementsHash -Content $reqContent
$stampPath = Join-Path $runtimeDir ".jiangchang-runtime-stamp.json"
$runtimeFingerprint = ""
if (Test-Path -LiteralPath $stampPath) {
try {
$stamp = Read-Utf8Text -Path $stampPath | ConvertFrom-Json
$runtimeFingerprint = [string]$stamp.fingerprint
} catch {
$runtimeFingerprint = ""
}
}
$statePath = Join-Path $runtimeDir ".skill-requirements\$slug.json"
$previousHash = ""
$previousFingerprint = ""
$previousStatus = ""
if (Test-Path -LiteralPath $statePath) {
try {
$previous = Read-Utf8Text -Path $statePath | ConvertFrom-Json
$previousHash = [string]$previous.requirementsHash
$previousFingerprint = [string]$previous.runtimeFingerprint
$previousStatus = [string]$previous.status
} catch {
$previousStatus = ""
}
}
$distributionNames = @(Get-DistributionNames -Lines $reqLines)
$unchanged = (
-not $ForceDeps -and
$previousStatus -eq "ok" -and
$previousHash -eq $reqHash -and
$runtimeFingerprint -and
$previousFingerprint -eq $runtimeFingerprint
)
if ($unchanged -and $distributionNames.Count -gt 0 -and (Test-DistributionsPresent -PythonExe $pythonExe -Names $distributionNames)) {
Write-Host "Requirements unchanged and already present in the shared environment. Skipping uv pip install." -ForegroundColor Green
} elseif ($DryRun) {
Write-Host "DryRun: would run uv pip install -r requirements.txt" -ForegroundColor Yellow
$distributionNames | ForEach-Object { Write-Host " - $_" }
} else {
$uv = Resolve-UvExecutable
Write-Host ">> Install skill requirements into the shared environment" -ForegroundColor DarkGray
Write-Host " uv=$uv"
$installed = $false
foreach ($mirror in $PypiMirrors) {
Write-Host ">> uv pip install --index-url $($mirror.Url)" -ForegroundColor DarkGray
& $uv pip install --index-url $mirror.Url --python $pythonExe -r $reqPath
if ($LASTEXITCODE -eq 0) {
$installed = $true
break
}
Write-Warning "Mirror $($mirror.Name) failed (exit $LASTEXITCODE). Trying the next one."
}
if (-not $installed) {
throw "requirements.txt install failed. Code is already at $destFull, but the shared environment is missing dependencies."
}
Write-RequirementsState -RuntimeDir $runtimeDir -Slug $slug -RequirementsPath $reqPath -RequirementsHash $reqHash -RuntimeFingerprint $runtimeFingerprint
Write-Host "Dependencies are installed in the shared environment." -ForegroundColor Green
}
}
if ($platformKitMin -and (Test-Path -LiteralPath $pythonExe)) {
$kitVersion = Get-InstalledDistributionVersion -PythonExe $pythonExe -Name "jiangchang-platform-kit"
if (-not $kitVersion) {
Write-Warning "jiangchang-platform-kit is not installed in the shared environment, but this skill requires >= $platformKitMin."
} elseif (-not (Compare-VersionPrefix -Installed $kitVersion -Required $platformKitMin)) {
Write-Warning "jiangchang-platform-kit $kitVersion is below this skill's requirement >= $platformKitMin. This script does not upgrade the platform kit."
} else {
Write-Host "platform kit $kitVersion satisfies >= $platformKitMin." -ForegroundColor Green
}
}
if ($SkipInitDb) {
Write-Host "Skipped init-db (-SkipInitDb). The database was not updated." -ForegroundColor Yellow
} else {
$initSkillDir = $destFull
if ($DryRun -and -not (Test-Path -LiteralPath (Join-Path $destFull "scripts\main.py"))) {
$initSkillDir = $sourceFull
}
Invoke-SkillInitDb -PythonExe $pythonExe -SkillDir $initSkillDir -DataRoot $resolvedDataRoot -UserId $resolvedUserId -DryRun:$DryRun
}
Write-Host ""
Write-Host "Done. Stop any running job, leave Data Management, then open it again." -ForegroundColor Cyan
Write-Host "Do not click Update in the skill market. That replaces this source with the encrypted package." -ForegroundColor Cyan

View File

@@ -736,6 +736,7 @@ class TestActionsManifest(unittest.TestCase):
"afterFailure",
"cellPresentation",
"CELL_PRESENTATION.md",
"整行",
"labelColumn",
"selectionMode",
"勾选驱动",

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Cell Presentation 契约:schema + 文档 + 示例。"""
"""Cell / Grid Presentation 契约:schema + 文档 + 示例。"""
from __future__ import annotations
import json
@@ -9,31 +9,34 @@ import unittest
from util.runtime_paths import get_skill_root
def _load_schema() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"schemas",
"skill-cell-presentation.schema.json",
)
with open(path, encoding="utf-8") as f:
def _skill_path(*parts: str) -> str:
return os.path.join(get_skill_root(), *parts)
def _load_json(*parts: str) -> dict:
with open(_skill_path(*parts), encoding="utf-8") as f:
return json.load(f)
def _load_example() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"examples",
"skill-cell-presentation.example.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_cell_schema() -> dict:
return _load_json("assets", "schemas", "skill-cell-presentation.schema.json")
def _load_cell_example() -> dict:
return _load_json("assets", "examples", "skill-cell-presentation.example.json")
def _load_grid_schema() -> dict:
return _load_json("assets", "schemas", "skill-grid-presentation.schema.json")
def _load_grid_example() -> dict:
return _load_json("assets", "examples", "skill-grid-presentation.example.json")
class TestCellPresentationContract(unittest.TestCase):
def test_schema_file_exists_and_parses(self) -> None:
schema = _load_schema()
schema = _load_cell_schema()
self.assertEqual(schema.get("title"), "SkillCellPresentationDocument")
self.assertIs(schema.get("additionalProperties"), False)
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
@@ -45,33 +48,38 @@ class TestCellPresentationContract(unittest.TestCase):
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
jsonschema.validate(instance=_load_example(), schema=_load_schema())
jsonschema.validate(instance=_load_cell_example(), schema=_load_cell_schema())
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
path = os.path.join(
get_skill_root(), "assets", "schemas", "skill-actions.schema.json"
)
with open(path, encoding="utf-8") as f:
actions_schema = json.load(f)
actions_schema = _load_json("assets", "schemas", "skill-actions.schema.json")
self.assertIn("cellPresentation", actions_schema["properties"])
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
def test_docs_and_example_exist(self) -> None:
root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
self.assertTrue(os.path.isfile(_skill_path("references", "CELL_PRESENTATION.md")))
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
_skill_path("assets", "schemas", "skill-cell-presentation.schema.json")
)
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json")
_skill_path("assets", "examples", "skill-cell-presentation.example.json")
)
)
self.assertTrue(
os.path.isfile(
_skill_path("assets", "schemas", "skill-grid-presentation.schema.json")
)
)
self.assertTrue(
os.path.isfile(
_skill_path("assets", "examples", "skill-grid-presentation.example.json")
)
)
def test_cell_presentation_md_covers_contract(self) -> None:
path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md")
path = _skill_path("references", "CELL_PRESENTATION.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
@@ -80,14 +88,77 @@ class TestCellPresentationContract(unittest.TestCase):
"warning",
"alert",
"_jiangchang_cell_presentation",
"_jiangchang_grid_presentation",
"cellPresentation",
"派生",
"任意 CSS",
"用途不限",
"schemaVersion",
"整行",
"整列",
"all",
"格子 > 区域 > 行 > 列",
"详情",
):
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
def test_grid_schema_file_exists_and_parses(self) -> None:
schema = _load_grid_schema()
self.assertEqual(schema.get("title"), "SkillGridPresentationDocument")
self.assertIs(schema.get("additionalProperties"), False)
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
tones = set(schema["$defs"]["tone"].get("enum") or [])
self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"})
self.assertIn("not", schema["$defs"]["ruleItem"])
def test_grid_example_validates_against_schema(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
jsonschema.validate(instance=_load_grid_example(), schema=_load_grid_schema())
def test_grid_schema_rejects_all_by_all(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
payload = {
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"rules": [
{
"ruleKey": "paint-everything",
"rows": "all",
"columns": "all",
"tone": "danger",
}
],
}
with self.assertRaises(jsonschema.ValidationError):
jsonschema.validate(instance=payload, schema=_load_grid_schema())
def test_internal_metadata_hides_presentation_tables(self) -> None:
from db.display_metadata import INTERNAL_METADATA_TABLES, METADATA_TABLES
self.assertIn("_jiangchang_cell_presentation", INTERNAL_METADATA_TABLES)
self.assertIn("_jiangchang_grid_presentation", INTERNAL_METADATA_TABLES)
self.assertTrue(INTERNAL_METADATA_TABLES <= METADATA_TABLES)
def test_template_actions_do_not_enable_presentation_by_default(self) -> None:
manifest = _load_json("assets", "actions.json")
self.assertNotEqual(manifest.get("cellPresentation", {}).get("enabled"), True)
def test_checklist_covers_grid_presentation(self) -> None:
path = _skill_path("development", "CHECKLIST.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("A6-15", text)
self.assertIn("cellPresentation", text)
self.assertIn("CELL_PRESENTATION.md", text)
if __name__ == "__main__":
unittest.main()

View File

@@ -406,6 +406,12 @@ def _scan_install_delivery_files(skill_root: str) -> list[str]:
continue
if suffix == ".sh" and not path.endswith(".sh"):
continue
# Root sync.ps1 is the manual local-host mirror. It installs
# requirements into the shared venv the same way the host does.
if os.path.basename(path) == "sync.ps1" and os.path.normcase(
os.path.dirname(os.path.abspath(path))
) == os.path.normcase(os.path.abspath(skill_root)):
continue
text = _read_text(path)
for pattern in INSTALL_FORBIDDEN_PATTERNS:
if pattern.search(text):

View File

@@ -595,6 +595,53 @@ metadata:
self.assertIn("readonly = 1", text)
self.assertIn("seed_column_metadata_batch", text)
self.assertIn("valid_column_names", text)
self.assertIn("可以**引用 `visible=0` 的列", text)
self.assertIn("default_sort_json` / ORDER BY", text)
def test_default_sort_json_allows_hidden_column_and_rejects_missing(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
seed_column_metadata_batch,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_default_sort_metadata
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.execute(
"CREATE TABLE demo_sort (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"
)
upsert_table_metadata(
cur,
table_name="demo_sort",
display_name="排序示例",
default_sort_json='[{"column":"title","direction":"asc"},{"column":"id","direction":"desc"}]',
)
seed_column_metadata_batch(
cur,
table_name="demo_sort",
columns=(
{"column_name": "id", "display_name": "编号", "visible": 0, "editable": 0},
{"column_name": "title", "display_name": "标题", "visible": 1, "editable": 1},
),
valid_column_names=["id", "title"],
)
errors, warnings = validate_default_sort_metadata(cur, "demo_sort")
self.assertEqual(errors, [])
self.assertTrue(any("visible=0" in w and "id" in w for w in warnings))
upsert_table_metadata(
cur,
table_name="demo_sort",
display_name="排序示例",
default_sort_json='[{"column":"missing_col","direction":"asc"}]',
)
errors, _ = validate_default_sort_metadata(cur, "demo_sort")
self.assertTrue(any("missing_col" in e for e in errors))
finally:
conn.close()
def test_default_visible_columns_json_upsert_and_migrate(self) -> None:
conn = sqlite3.connect(":memory:")

View File

@@ -327,6 +327,11 @@ class TestDocsStandards(unittest.TestCase):
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
)
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-grid-presentation.schema.json")
)
)
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
@@ -375,6 +380,7 @@ class TestDocsStandards(unittest.TestCase):
"表增删改与退役",
"drop_deprecated_user_table",
"孤儿物理表",
"_jiangchang_grid_presentation",
):
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
@@ -416,6 +422,8 @@ class TestDocsStandards(unittest.TestCase):
text = self._read("assets/examples/README.md")
self.assertIn("visible-when-action.example.json", text)
self.assertIn("selection-mode-optional.action.example.json", text)
self.assertIn("skill-cell-presentation.example.json", text)
self.assertIn("skill-grid-presentation.example.json", text)
checklist = self._read("development/CHECKLIST.md")
self.assertIn("A4-7", checklist)
self.assertIn("visibleWhen", checklist)

View File

@@ -70,6 +70,20 @@ class TestHtmlPageService(unittest.TestCase):
self.assertIn("overflow-wrap: anywhere", template)
self.assertNotIn("max-width:", template)
def test_actions_contract_keeps_shell_pages_and_call(self) -> None:
from util.runtime_paths import get_skill_root
root = get_skill_root()
actions_path = os.path.join(root, "references", "ACTIONS.md")
with open(actions_path, encoding="utf-8") as f:
actions = f.read()
self.assertIn("assets/html/pages", actions)
self.assertIn("jiangchang.call", actions)
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "html", "example-report.html")))
self.assertFalse(
os.path.isfile(os.path.join(root, "assets", "html", "pages", "example-report.html"))
)
if __name__ == "__main__":
unittest.main()

View File

@@ -44,6 +44,8 @@ class TestSlugNaming(unittest.TestCase):
for slug in (
"download-settlement-report-amazon",
"add-product-shopee",
"generate-monthly-statements",
"reconcile-finance",
"generate-article-all",
"your-skill-slug",
"account-manager",
@@ -56,8 +58,7 @@ class TestSlugNaming(unittest.TestCase):
cases = (
"amazon-download-settlement-report",
"enter-cross-border-sales-receipt-voucher-kingdee",
"reconcile-finance",
"download-settlement-report-unknownplat",
"generate",
"foobar-settlement-report-amazon",
)
for slug in cases:
@@ -78,6 +79,19 @@ class TestSlugNaming(unittest.TestCase):
self.assertTrue(errors)
self.assertTrue(any("48" in err for err in errors))
def test_plain_slug_without_platform(self) -> None:
slug = "generate-monthly-statements"
self.assertEqual(classify_slug_form(parse_slug_segments(slug), slug=slug), "plain")
errors, warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}")
self.assertEqual(warnings, [])
def test_default_suffix_warns_but_does_not_fail(self) -> None:
slug = "generate-article-default"
errors, warnings = validate_slug_semantics(slug)
self.assertEqual(errors, [], msg=f"errors={errors!r}")
self.assertTrue(any("default" in item for item in warnings), msg=f"warnings={warnings!r}")
def test_legacy_exempt_set(self) -> None:
self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS)
self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)

View File

@@ -51,6 +51,7 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar`
- **不**生成任何具体平台业务 Action
- **不**自动 `git init`;打印后续 Git 与测试命令
- 根目录同时复制 `release.ps1`(发布到服务器)和 `sync.ps1`(同步明文到本机宿主)
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
@@ -58,6 +59,8 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
| 文件 | 说明 |
|------|------|
| `release.ps1` | 发布到服务器;保留 |
| `sync.ps1` | 把明文同步到本机宿主;保留。用法见 `development/DEVELOPMENT.md` §14.6 |
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |