Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c113f68bf4 | |||
| 5ff8899b48 | |||
| 5018a1801d | |||
| b9c82b5520 | |||
| 0954ad542a | |||
| 4d078e8258 | |||
| 6855ac7078 | |||
| e9b34747cc | |||
| f5bf35c722 | |||
| 34bbb97406 | |||
| 337f1a9098 | |||
| f02a34e751 | |||
| e7f42e835a | |||
| a0f1c15b85 | |||
| f8d03fad4d | |||
| 5b0e41199d |
@@ -59,6 +59,7 @@ AGENTS.md # 本文档
|
||||
|
||||
| Skill | 说明 |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
|
||||
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
|
||||
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
||||
|
||||
@@ -36,6 +36,7 @@ AGENTS.md # 详细规范与架构说明
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
|
||||
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
|
||||
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# ACK 飞书多维表格 Bug 收件箱
|
||||
|
||||
## 目标
|
||||
|
||||
让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并
|
||||
反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、
|
||||
可幂等追踪的 ACK 任务并启动三角色闭环。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不用 Skill 承担人工记录入口。
|
||||
- 不抓取公开网页或依赖浏览器登录态。
|
||||
- 不把 App Secret、access token 或飞书用户凭据写入项目。
|
||||
- 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书。
|
||||
- 不把多条互不相关的 Bug 合成一次 Developer 派发。
|
||||
|
||||
## 项目配置契约
|
||||
|
||||
可选配置位于 `docs/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
|
||||
配置存在时必须包含:
|
||||
|
||||
| 字段 | 约束 |
|
||||
|------|------|
|
||||
| `provider` | 固定为 `feishu-base` |
|
||||
| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 |
|
||||
| `profile` | `lark-cli` profile 名称;每次命令显式传入 |
|
||||
| `baseToken` | 飞书 Base token |
|
||||
| `tableId` | 数据表 ID |
|
||||
| `viewId` | 只包含可导入记录的 `ACK Ready` 视图 ID |
|
||||
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
|
||||
|
||||
`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、
|
||||
`acceptance`、`priority`、`attachments`、`updatedAt`。启用审核前协作时还必须映射
|
||||
`fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。
|
||||
|
||||
## CLI 契约
|
||||
|
||||
- 可执行文件固定为可信路径中的 `lark-cli`。
|
||||
- 允许官方 npm 包生成且 package/bin 映射校验通过的 `run.js` wrapper,并解析到包内
|
||||
native binary 执行;拒绝缺少 native binary 或其它同名软链接目标。
|
||||
- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。
|
||||
- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。
|
||||
- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。
|
||||
- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改
|
||||
配置映射的 `fixLogic` 与 `acceptance`。
|
||||
- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务
|
||||
校验器拒绝审核字段与该 hash 不一致的 reviewed task。
|
||||
- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。
|
||||
- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。
|
||||
- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与
|
||||
元数据一致。
|
||||
- 标准输出必须是单一 JSON;CLI stderr 只作为错误摘要,不把可能的凭据写入结果。
|
||||
|
||||
## 标准化结果
|
||||
|
||||
读取器输出批次元数据和 `records`。每条记录至少包含:
|
||||
|
||||
- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256
|
||||
后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本;
|
||||
- `recordId` 与 `updatedAt`;
|
||||
- 绑定来源事实、附件元数据和审核字段的 `draftRevision`;
|
||||
- title、actual、expected、steps、可选 fixLogic、acceptance、priority;
|
||||
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority;
|
||||
- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用;
|
||||
- 原始字段中无法映射但不影响导入的警告。
|
||||
|
||||
输出不得包含 App ID、App Secret、Base token、附件 file token、tenant/user access
|
||||
token 或 CLI 配置文件内容。
|
||||
|
||||
## Coordinator 整理规则
|
||||
|
||||
1. 先运行读取器 `check` 确认 `lark-cli` 和项目 profile,再单独核对 app 已获得所需读写
|
||||
scope;`check` 不把用户 token 状态当作 app/bot scope 证明。
|
||||
2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift
|
||||
整理动作;需要看截图时使用临时下载目录。
|
||||
3. 将记录分类并在飞书补全 `fixLogic` 与 `acceptance`;用户反馈时继续写回同一记录。
|
||||
4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改
|
||||
应用代码。通过后每条导入任务保存 `source.kind=feishu-base`、`source.ref`、
|
||||
`source.recordId`、`source.updatedAt`、`source.workflow`、`source.approvedRevision`、
|
||||
`source.approvedPayloadHash`,并把截图观察转成文字证据。
|
||||
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
|
||||
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
|
||||
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
|
||||
8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed;
|
||||
steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文
|
||||
补齐并写回飞书。只有批准后的最终版本才导入任务板。
|
||||
9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分
|
||||
来源事实的残缺行不得静默跳过。
|
||||
|
||||
## 可观测验收信号
|
||||
|
||||
1. 两个假租户 profile 同时存在时,项目指定 `tenant-b`,所有记录和附件命令都显式包含 `--profile tenant-b`,不会读取 active 的 `tenant-a`。
|
||||
2. 只读取配置的 `ACK Ready` view ID,并投影配置字段;不查询整张表或其它视图。
|
||||
3. 一条含截图附件的假记录被标准化为稳定 `sourceRef`、完整文字字段和本地附件路径,输出中不存在任何 secret/token 凭据。
|
||||
4. CLI 缺失、profile 缺失、畸形 JSON、错位矩阵、分页越界、路径穿越或附件下载失败均返回非零退出码且不输出伪成功结果。
|
||||
5. ACK 文档明确要求按 `source.ref` 幂等整理;同一读取结果重复提交不会生成第二个来源任务。
|
||||
6. 现有无 `bugIntake` 的 ACK 项目仍能通过任务板校验并按原流程工作。
|
||||
7. 缺少 steps、acceptance、priority 的记录仍生成 create/refresh 计划并列出
|
||||
`enrichmentRequired`;全空误建行被跳过,缺 title/actual/expected 的部分记录失败。
|
||||
|
||||
## 最脆弱假设与降级
|
||||
|
||||
本设计假设官方 `lark-cli` 的 Base JSON 输出保持 `fields`、`record_id_list` 与行矩阵
|
||||
对应关系。读取器必须校验三者长度和字段映射;若上游输出契约变化,立即失败并提示
|
||||
升级适配器,不能错列生成 Bug。飞书或 CLI 不可用时,只停止新的同步,已经进入
|
||||
`tasks.yaml` 的任务继续按 ACK 闭环执行。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
||||
version: 1
|
||||
updatedAt: "2026-08-01T12:43:06+08:00"
|
||||
project:
|
||||
name: "skills"
|
||||
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
|
||||
verificationRegistry: {}
|
||||
entries: []
|
||||
@@ -0,0 +1,71 @@
|
||||
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
|
||||
|
||||
> 本项目基于 ACK Skill v0.12.0。通用规范由 `/ack` 从 Skill 自身的
|
||||
> `references/` 读取;本文件只保存当前仓库差异。
|
||||
|
||||
## 项目概览
|
||||
|
||||
- 项目:`skills`
|
||||
- 技术栈:Python 3、Markdown、JSON Schema
|
||||
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
|
||||
- Base URL:`n/a`
|
||||
- 任务板:`docs/ack/tasks.yaml`
|
||||
- 项目知识:`docs/ack/knowledge.yaml`
|
||||
- 覆盖层文件:`docs/ack/project.md`
|
||||
|
||||
## 通用规范(由 ACK Skill 按需读取)
|
||||
|
||||
- 开始需求:`references/kickoff.md`
|
||||
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
|
||||
- 模型档位与升级规则:`references/model-routing.md`
|
||||
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
|
||||
- 验收信号与三轮策略:`references/optimization-method.md`
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令:`references/orca-adapter.md`
|
||||
|
||||
## Worker 路由
|
||||
|
||||
机器可校验的 worker profile、允许 worktree 和 receipt 只以
|
||||
`docs/ack/tasks.yaml` 为准。本次 Developer 使用当前 `record-bug` worktree;Test
|
||||
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
|
||||
|
||||
## 路径权限
|
||||
|
||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||
|------|:-----------:|:----:|:---------:|------|
|
||||
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
|
||||
| `docs/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
|
||||
| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 |
|
||||
| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 |
|
||||
| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
|
||||
| 其它 `tests/test_ack*.py` | Read-only | Read-only | Read-only | 既有 ACK 回归测试 |
|
||||
| 临时测试目录 | Read-only | R/W | R/W | 必须由 `tempfile`/`mktemp -d` 创建,不保存凭据 |
|
||||
| `~/.lark-cli/` | Read-only | Read-only | Read-only | 不读取、不修改真实飞书 profile;测试只能使用假 CLI |
|
||||
|
||||
## 命令
|
||||
|
||||
Developer 白盒验证:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'
|
||||
python3 -m unittest tests.test_ack_skill tests.test_ack_tasks_validation
|
||||
python3 -m compileall -q skills/ack/scripts
|
||||
```
|
||||
|
||||
Test 黑盒复测:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
|
||||
python3 -m unittest discover -s tests -p 'test_ack*.py'
|
||||
python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||
python3 skills/ack/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||
```
|
||||
|
||||
## 本次任务硬规则
|
||||
|
||||
- 只支持官方 `lark-cli`,所有读取命令必须显式携带项目配置的 `--profile`,不能依赖当前 active profile。
|
||||
- 项目只保存 profile 名称、Base/table/view 标识和字段映射;App Secret、token 与用户凭据不得进入仓库、任务板或日志。
|
||||
- 飞书接入第一版只读,不更新或删除表格记录,不切换 CLI active profile。
|
||||
- 外部 CLI 缺失、profile 不存在、返回结构异常、附件下载失败时 fail closed,不回退到网页抓取。
|
||||
- 只有 `ACK Ready` 视图中的记录可进入整理;每条记录保留稳定 `record_id` 来源引用,重复读取不得制造重复 ACK 来源。
|
||||
- 不修改 `AGENTS.md`、`CLAUDE.md`,不提交或推送。
|
||||
@@ -0,0 +1,533 @@
|
||||
version: 1
|
||||
updatedAt: "2026-08-01T13:34:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.12.0"
|
||||
project:
|
||||
name: "skills"
|
||||
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
baseUrl: "n/a"
|
||||
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
allowedWorktrees:
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
standard: ["gpt-5.6-terra"]
|
||||
strong: ["gpt-5.6-sol"]
|
||||
test:
|
||||
standard: ["gpt-5.6-luna"]
|
||||
profiles:
|
||||
codex-dev-standard:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
codex-test-standard:
|
||||
role: "test"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
codex-dev-strong:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "strong"
|
||||
model: "gpt-5.6-sol"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
defaults:
|
||||
developer: "codex-dev-standard"
|
||||
test: "codex-test-standard"
|
||||
developerUpgraded: "codex-dev-strong"
|
||||
|
||||
workerReceipts:
|
||||
- receiptVersion: 1
|
||||
id: "WR-1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
|
||||
launchId: "1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
|
||||
profileId: "codex-dev-standard"
|
||||
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
|
||||
launchFingerprint: "sha256:7de2f9ebf3065e7c044150fa87aed39582a8018b63a0dabac22194bf52ad52b7"
|
||||
slot: 1
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A1"
|
||||
role: "developer"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-terra"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=medium"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_4d6278c9-83ac-4876-a29d-022adc346e6c"
|
||||
incarnationId: "6daffb4c-e705-47f3-b7c2-d1ee1fb16c56"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T04:46:51.629061Z"
|
||||
createdAt: "2026-08-01T04:46:42.869861Z"
|
||||
receiptHash: "sha256:93e7e4454438a4d1c9a64eb7429563bb4b71348715cc4cb3e42c96cb9ca75fa4"
|
||||
- receiptVersion: 1
|
||||
id: "WR-035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
|
||||
launchId: "035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
|
||||
profileId: "codex-test-standard"
|
||||
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
|
||||
launchFingerprint: "sha256:981fe3c23594573f564de9ab321e724324962958ed689ad3c0c98d7d165c1aa9"
|
||||
slot: 2
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A1"
|
||||
role: "test"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-luna"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=low"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_ab858ba7-b972-45fb-bf50-61e96ced2830"
|
||||
incarnationId: "9e20c158-7ac3-4a18-8dca-f9fec50126d6"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T04:57:33.909375Z"
|
||||
createdAt: "2026-08-01T04:57:25.086609Z"
|
||||
receiptHash: "sha256:1245532d66814f5d3b2a6a3b22293ead597f04390e628333569a7ba8763d0a84"
|
||||
- receiptVersion: 1
|
||||
id: "WR-3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
|
||||
launchId: "3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
|
||||
profileId: "codex-dev-standard"
|
||||
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
|
||||
launchFingerprint: "sha256:8264af6531e9828c0f994845b3a24931e22abea72d8a1d54a7c764577e544873"
|
||||
slot: 3
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A2"
|
||||
role: "developer"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-terra"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=medium"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c"
|
||||
incarnationId: "f77f05ff-fb2b-4099-ab03-070f61671673"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:01:26.824435Z"
|
||||
createdAt: "2026-08-01T05:01:17.965038Z"
|
||||
receiptHash: "sha256:c20c47c7491b5b399d2c29fce67a7035b432969bda0ccb74948eb03c3d085380"
|
||||
- receiptVersion: 1
|
||||
id: "WR-12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
|
||||
launchId: "12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
|
||||
profileId: "codex-test-standard"
|
||||
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
|
||||
launchFingerprint: "sha256:621c7d5d6b7182d9a1ebf211fa11a80d7b100a3b682f8800f61031a651f5bf2a"
|
||||
slot: 4
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A2"
|
||||
role: "test"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-luna"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=low"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_1ec9d2e1-a1b6-45db-8477-097e39a93ee4"
|
||||
incarnationId: "a6f79ac3-9cc6-40a1-a38a-e7b7c2a49b02"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:05:36.189001Z"
|
||||
createdAt: "2026-08-01T05:05:27.500696Z"
|
||||
receiptHash: "sha256:824227bda0e78558807e10da9d89ec32ee4d7f37d782b866bca4629c5ac9e8fb"
|
||||
- receiptVersion: 1
|
||||
id: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
|
||||
launchId: "90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
|
||||
profileId: "codex-dev-standard"
|
||||
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
|
||||
launchFingerprint: "sha256:97ef498caf4a5f0d48fef22773c942a5065dfcf5cecefa22a6ad21df8d04bcd1"
|
||||
slot: 5
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
role: "developer"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-terra"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=medium"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_88079d90-29a3-48ac-9b97-bec011abc249"
|
||||
incarnationId: "5d4f785f-e0b5-4ab3-9de3-72ebc2da3b06"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:16:40.565911Z"
|
||||
createdAt: "2026-08-01T05:16:31.788668Z"
|
||||
receiptHash: "sha256:c62417b2ee8e9815228a18b2ee1be84b2844dada50eb9703a061e08e51e57eb7"
|
||||
- receiptVersion: 1
|
||||
id: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
|
||||
launchId: "8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
|
||||
profileId: "codex-test-standard"
|
||||
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
|
||||
launchFingerprint: "sha256:6c7a45f7dd119bb6ef3bfaed7961c30f257285d940f248e0b16678dcad1bd19d"
|
||||
slot: 6
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
role: "test"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-luna"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=low"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
|
||||
incarnationId: "6e37ac75-5725-410c-87af-63f8bb42a911"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:25:08.564556Z"
|
||||
createdAt: "2026-08-01T05:24:59.720042Z"
|
||||
receiptHash: "sha256:dcbed5b2a3fa99312deba61951253992741eca885068e8f649613bd97eb3f59f"
|
||||
|
||||
summary:
|
||||
verified: ["FEISHU-001"]
|
||||
open: []
|
||||
failedRetest: []
|
||||
leftovers: []
|
||||
|
||||
tasks:
|
||||
- id: "FEISHU-001"
|
||||
type: "feature"
|
||||
title: "ACK 从飞书多维表格按项目 profile 读取并整理 Bug"
|
||||
priority: "P1"
|
||||
status: "verified"
|
||||
assignee: "test"
|
||||
component: "skills/ack"
|
||||
specRefs:
|
||||
- "docs/ack-feishu-bug-intake.md"
|
||||
testRefs:
|
||||
- "tests/test_ack_feishu_intake_unit.py"
|
||||
- "tests/test_ack_feishu_intake_e2e.py"
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
description: >
|
||||
扩展完整 ACK Skill,使项目可以在 tasks.yaml 中声明飞书多维表格 Bug 收件箱和
|
||||
lark-cli profile。ACK 通过可信、无 shell 的读取器读取 ACK Ready 视图、按需
|
||||
下载截图并输出可幂等整理的标准化记录;不同项目不会串用飞书租户。
|
||||
|
||||
stepsToReproduce:
|
||||
- "创建两个假 lark-cli profile:tenant-a 为 active,项目配置 tenant-b"
|
||||
- "在 tenant-b 的 ACK Ready 视图返回一条带截图附件的 Bug 记录"
|
||||
- "运行 ACK 飞书读取器 check/fetch,并重复提交同一标准化结果"
|
||||
|
||||
expected: >
|
||||
读取器每次显式使用 tenant-b,仅读取配置 view,正确标准化文字与截图附件,
|
||||
对所有错误 fail closed;ACK 按稳定 source.ref 整理,重复来源不产生新任务,
|
||||
未配置飞书的旧项目保持兼容。
|
||||
|
||||
actual: >
|
||||
当前 ACK 没有飞书 Bug 数据源、CLI profile 配置、附件读取或来源幂等规则。
|
||||
|
||||
evidence:
|
||||
browser: "飞书多维表格记录可包含文字字段和截图附件;本次使用假 CLI 离线复现"
|
||||
api: "官方 lark-cli base +record-list 与 +record-download-attachment"
|
||||
logs: "本机当前没有 lark-cli,验证必须通过隔离假 CLI 完成"
|
||||
|
||||
verification:
|
||||
commands:
|
||||
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'"
|
||||
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'"
|
||||
- "python3 -m unittest discover -s tests -p 'test_ack*.py'"
|
||||
- "python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml"
|
||||
browser:
|
||||
page: "n/a"
|
||||
checks:
|
||||
- "假 CLI 记录证明所有数据命令显式使用 tenant-b profile"
|
||||
- "标准化输出包含唯一 sourceRef 与本地截图路径"
|
||||
- "重复来源整理结果只对应一个 ACK 任务"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
taskId: "task_9bc1dba1e21a"
|
||||
dispatchId: "ctx_0791582a526f"
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
taskId: "task_4f70d2496d13"
|
||||
dispatchId: "ctx_66ab3460dda2"
|
||||
rounds:
|
||||
- round: 1
|
||||
attemptId: "FEISHU-001-A1"
|
||||
result: failed
|
||||
evidence: >
|
||||
Isolated fake tenant and screenshot intake passed profile isolation,
|
||||
download containment, fail-closed behavior, organization rules and
|
||||
legacy validation, but normalized output leaked baseToken inside
|
||||
sourceRef and exposed the attachment file token.
|
||||
- round: 2
|
||||
attemptId: "FEISHU-001-A2"
|
||||
result: failed
|
||||
evidence: >
|
||||
Strengthened isolated E2E passed after the secret-output fix, but
|
||||
Coordinator final gate compared the adapter with official lark-cli
|
||||
source: profile list returns a raw JSON array and has no --format
|
||||
flag, while the adapter requires an object and sends --format.
|
||||
Executable lookup also trusts arbitrary PATH instead of the fixed
|
||||
trusted directories required by the product spec; template source
|
||||
reference still documents the obsolete raw form.
|
||||
- round: 3
|
||||
attemptId: "FEISHU-001-A3"
|
||||
result: passed
|
||||
evidence: >
|
||||
Fresh Test ran a file-backed fake Feishu submission containing all
|
||||
Bug text fields and a screenshot through the production check/fetch
|
||||
entry points. Six focused E2E tests and five unit tests passed. The
|
||||
run verified the official raw profile array and data.data matrix,
|
||||
explicit tenant-b selection, trusted executable resolution,
|
||||
screenshot containment, stable opaque sourceRef deduplication and
|
||||
fail-closed handling for malformed, ambiguous, non-progressing and
|
||||
over-limit responses. Validators, skiff check, compileall and diff
|
||||
checks also passed.
|
||||
|
||||
resolution:
|
||||
fixedBy: "term_88079d90-29a3-48ac-9b97-bec011abc249"
|
||||
verifiedBy: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
|
||||
verifiedAt: "2026-08-01T13:31:40+08:00"
|
||||
leftoverReason: null
|
||||
evidence:
|
||||
developer: >
|
||||
FEISHU-001-A1 implemented ACK 0.12.0 Feishu Base intake. Focused unit
|
||||
tests, ACK contract tests, compileall, task validation and diff check
|
||||
passed. The fresh worker could not deliver worker_done through its
|
||||
workspace-write sandbox because Orca relay/FUSE was unavailable;
|
||||
Coordinator preserved the terminal evidence in Orca task
|
||||
task_ed87b3386a80 and did not treat it as final verification.
|
||||
testRound1: >
|
||||
Fresh Test term_ab858ba7-b972-45fb-bf50-61e96ced2830 created the
|
||||
isolated E2E fixture and found secret-safe output failing while all
|
||||
other targeted signals passed. Evidence is retained in Orca task
|
||||
task_251a9b5265cd; Test relay was unavailable for the same sandbox
|
||||
reason, so this remains a failed retest rather than verification.
|
||||
developerRound2: >
|
||||
Fresh Developer term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c changed
|
||||
sourceRef to a domain-separated SHA-256 digest and kept file tokens
|
||||
internal to download calls. Focused unit, ACK contract, read-only E2E,
|
||||
compileall and diff checks passed; independent round-2 Test is still
|
||||
required.
|
||||
coordinatorGateRound2: >
|
||||
Targeted Test evidence was green, but final source-contract review
|
||||
against official larksuite/cli cmd/profile/list.go rejected the round:
|
||||
actual profile list output is a JSON array, the command exposes no
|
||||
format flag, and the current reader resolves lark-cli through caller
|
||||
PATH rather than fixed trusted directories.
|
||||
developerRound3: >
|
||||
Fresh Developer term_88079d90-29a3-48ac-9b97-bec011abc249 added the
|
||||
trusted resolver, official raw profile-list and data.data parsing,
|
||||
strict envelopes, sanitized execution PATH, revised unit coverage and
|
||||
complete setup/source examples. Five focused unit tests, 34 ACK
|
||||
contracts (one skipped), compileall, task validation and diff check
|
||||
passed; final independent Test remains required.
|
||||
testRound3: >
|
||||
Fresh Test term_44ab2675-445a-4f51-a0f5-83960bc1ca3b passed six
|
||||
isolated E2E cases and all five Feishu unit tests. It exercised the
|
||||
production entry points with a real fake-CLI subprocess and a separate
|
||||
submitted-bug JSON file, downloaded a screenshot into a temporary
|
||||
directory, repeated the fetch to prove a stable opaque sourceRef, and
|
||||
simulated ACK source.ref organization without duplicates. The four
|
||||
failures in the wider legacy suite reproduce unchanged on base commit
|
||||
f08edb6 and are classified as existing environment/layout failures.
|
||||
Orca worker_done transport was unavailable inside the worker sandbox
|
||||
because FUSE was absent; the full fresh-terminal evidence is retained
|
||||
in Orca task task_4f70d2496d13.
|
||||
+26
-6
@@ -49,6 +49,10 @@ docs/ack/
|
||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
||||
目录读取。
|
||||
|
||||
ACK 从当前命令指定的 `--project-root/docs/ack/` 定位项目状态,不要求在
|
||||
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录仍由
|
||||
`--worktree` 指定,并且必须命中绝对路径白名单 `allowedWorktrees`。
|
||||
|
||||
## Skill 结构
|
||||
|
||||
```text
|
||||
@@ -59,7 +63,7 @@ skills/ack/
|
||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
|
||||
├── examples/ # 完整示例
|
||||
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
||||
└── scripts/ # 状态校验、任务/知识选择、安全验证执行与结构化 worker launcher
|
||||
```
|
||||
|
||||
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
|
||||
@@ -84,10 +88,19 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
||||
--task-id BUG-001
|
||||
|
||||
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
|
||||
--component web --path web/app.py --tag long-running-service --limit 10
|
||||
```
|
||||
|
||||
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
|
||||
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
|
||||
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
|
||||
上下文。
|
||||
|
||||
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
|
||||
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
|
||||
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
|
||||
@@ -182,15 +195,21 @@ Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有
|
||||
审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化
|
||||
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
|
||||
|
||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。由于 Orca 当前
|
||||
不能证明旧终端的原始 argv/模型/权限,v0.10 不自动复用既有 worker;每次自动派发
|
||||
都重新 `plan` 并用 expected fingerprint 启动 fresh worker。
|
||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
|
||||
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
|
||||
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
|
||||
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
|
||||
启动 fresh worker。
|
||||
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
|
||||
重复启动,结果不确定时必须先 reconcile。
|
||||
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
|
||||
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
|
||||
直接重试。
|
||||
|
||||
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
|
||||
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
|
||||
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
|
||||
|
||||
## 开始一个需求
|
||||
|
||||
初始化完成后可以直接说:
|
||||
@@ -223,5 +242,6 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、
|
||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
||||
`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的
|
||||
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
|
||||
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型。旧项目可以不迁移
|
||||
而继续使用原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||
|
||||
+66
-15
@@ -53,6 +53,9 @@ description: >-
|
||||
- 只写项目差异,不复制 `references/` 中的通用规范。
|
||||
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
|
||||
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
|
||||
项目状态固定从当前项目根的 `docs/ack/` 推导,不写入 `repoPath` 或 `devWorktree`;
|
||||
worker 的绝对路径只保存在 `project.orchestration.allowedWorktrees` 并在派发时通过
|
||||
`--worktree` 显式选择。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。
|
||||
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
||||
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
||||
猜测并激活知识。
|
||||
@@ -83,11 +86,14 @@ description: >-
|
||||
- `docs/ack/tasks.yaml`
|
||||
- `docs/ack/knowledge.yaml`
|
||||
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
|
||||
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
|
||||
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
|
||||
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
||||
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
||||
存在。
|
||||
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
|
||||
3. 查找未替换占位符,并核对项目根、覆盖层路径、Developer 白盒命令、Test
|
||||
黑盒命令和 Base URL。
|
||||
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
||||
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
|
||||
@@ -96,21 +102,29 @@ description: >-
|
||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
||||
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
||||
缺少可选交付配置而宣称失败。
|
||||
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
5. 若存在 `project.bugIntake`,运行
|
||||
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。
|
||||
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
|
||||
`references/feishu-bug-intake.md`。
|
||||
6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
||||
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||
必须同时为空或同时填写。
|
||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
|
||||
## 工作
|
||||
|
||||
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
|
||||
2. 依次读取:
|
||||
- `docs/ack/project.md`
|
||||
- `docs/ack/tasks.yaml`
|
||||
- 运行 `python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml`,只读取
|
||||
`project`、`summary` 和默认可工作状态的任务;已知当前任务时传
|
||||
`--task-id <ack-task-id>`。选择器会解析并校验完整任务板,并只附带选中任务引用的
|
||||
receipt 与 delivery run。命中超过默认预算时用 `--task-id` / `--status` 缩小,
|
||||
不直接回退为输出完整 `tasks.yaml`。
|
||||
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
|
||||
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
||||
- `<ack-skill-dir>/references/kickoff.md`
|
||||
@@ -121,17 +135,40 @@ description: >-
|
||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||
不全量注入知识库。
|
||||
`project.bugIntake.workflow` 为 `clarified-writeback-v1`(推荐)或
|
||||
`reviewed-writeback-v1`(兼容旧项目)时,按
|
||||
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
|
||||
读取用户填写的 Bug。新工作流中,用户只维护标题、详细描述和附件;Coordinator 根据
|
||||
来源事实与项目上下文整理问题说明、期望效果和可观测验收标准,不在收件箱写修复逻辑,
|
||||
只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
|
||||
用户针对当前 `draftRevision` 明确审核通过并亲自在飞书把状态改为 `已确认` 前,不创建
|
||||
或刷新 `tasks.yaml` 任务、不启动 worker、不派发 Developer/Test,也不修改应用代码。
|
||||
Coordinator 不得自行写入 `已确认`。审核通过后重新读取,要求 revision 与批准值完全
|
||||
一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
|
||||
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
|
||||
payload hash 通过后,再用 `mark-imported` 把最终任务 ID 与同一 revision 写回飞书,
|
||||
才进入三角色闭环。未声明 workflow 的旧八字段配置只按
|
||||
`read-only-v1` 兼容,不得写回;
|
||||
标题、详细描述和附件是来源事实,不得把 Coordinator 推断伪装成用户原文;整行空白
|
||||
记录按批次 warning 跳过。
|
||||
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
||||
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
||||
然后交给用户确认;若启用了交付,必须默认把 `defaultProfile`、目标、停止点和需要
|
||||
审批的步骤放入同一份计划,不能静默省略。用户可明确取消本轮交付;确认前不派发
|
||||
实现,也不执行交付。
|
||||
5. 创建或更换 worker 时,只使用
|
||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
||||
executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为
|
||||
`launch --expected-launch-fingerprint` 传入。v0.10 不根据持久化 receipt 自动
|
||||
复用旧终端;每次自动派发都创建 fresh worker,receipt 只作审计与 dispatch
|
||||
关联。
|
||||
`launch --expected-launch-fingerprint` 传入。派发前先寻找同一 ACK 运行内的空闲
|
||||
worker;只有角色、profile、worktree 和启动身份仍完全匹配,且后端能清理历史消息、
|
||||
返回可核对的新会话身份时才复用。不得复用正在工作、等待回报或状态不明的 worker;
|
||||
任一条件不符、清理能力不存在或无法确认清理成功时创建 fresh worker。持久化
|
||||
receipt 只作审计与 dispatch 关联,不能单独授权复用。当前 Orca 终端接口不能提供
|
||||
可验证的历史消息清理,因此使用 Orca 时仍走 fresh worker。
|
||||
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
|
||||
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报
|
||||
`knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`;
|
||||
@@ -140,15 +177,27 @@ description: >-
|
||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
||||
也不要给 runner 注入额外命令或参数。
|
||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
|
||||
`leftover` 并继续其它任务。
|
||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。三轮预算只计算 Test 已对齐正确
|
||||
服务、数据和工具后实际执行验收所得的产品失败;环境失败不占复验轮次,不写
|
||||
`failed_retest`,而写入 `dispatch.environmentIncidents`。Coordinator 先做一次有界、
|
||||
安全的恢复;事件未解决、需要用户动作或会阻断本轮时,立即向用户报告原因、影响、
|
||||
已尝试动作、下一恢复动作和明确的 `userAction`;即使已自动恢复,也要在最终报告汇总。
|
||||
每项最多三轮有效产品复验,仍失败才记录 `leftover` 并继续其它任务。细则见
|
||||
`references/optimization-method.md` §4。
|
||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
||||
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
|
||||
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
|
||||
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
|
||||
run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署
|
||||
必须在对应步骤再次取得明确批准。
|
||||
run,不回写成任务失败。开发或测试环境完成构建、部署和健康检查后写
|
||||
`validation_ready`,并把访问地址、验证范围和用户下一步交给用户;不能停在
|
||||
`verified` 却声称整轮 ACK 已结束。默认 profile 最多到 `validation_ready` 或
|
||||
`review_ready`,稳定发布和生产部署必须在对应步骤再次取得明确批准。
|
||||
11. Coordinator 最后标记整轮任务完成后,回收所有只属于 `verified` 任务的 worker
|
||||
终端,并核对关闭回执;历史 receipt 和任务证据继续保留。任何还被 `open`、
|
||||
`dispatched`、`fixed_by_dev`、`retesting`、`blocked`、`failed_retest`、`leftover`
|
||||
或未解决环境事件引用的终端都保留,不设置 TTL,也不能因为同一终端还关联过
|
||||
`verified` 任务而误关。若关闭结果不确定,记录并报告,不重复关闭或伪报已回收。
|
||||
|
||||
## 交付配置维护
|
||||
|
||||
@@ -172,7 +221,7 @@ description: >-
|
||||
- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成
|
||||
v0.10 自动 worker 的合法配置;当前一律 fail closed。
|
||||
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
|
||||
v0.10 不自动复用既有 worker。
|
||||
没有可信空闲状态、配置匹配和历史消息清理证明时不复用既有 worker。
|
||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||
@@ -182,6 +231,8 @@ description: >-
|
||||
维护中修改。
|
||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务
|
||||
视图,写回前仍运行完整任务板校验。
|
||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
|
||||
ACK Skill 目录读取。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.11.0
|
||||
0.15.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||
|
||||
> 本项目基于 ack v0.11.0。
|
||||
> 本项目基于 ack v0.15.0。
|
||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
@@ -90,14 +90,15 @@ path/args。
|
||||
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
|
||||
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
|
||||
worker;持久化 receipt 只作审计,不授权自动复用旧终端。
|
||||
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
|
||||
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
|
||||
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||
`candidate` 不派发,知识库不全量注入。
|
||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
||||
- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与
|
||||
- 交付只在任务 `verified` 后运行;默认 profile 停在 `validation_ready` 或 `review_ready`。stable 发布与
|
||||
production 部署保留显式 approval,配置变更只影响下一次 run。
|
||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
||||
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
version: 1
|
||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.11.0"
|
||||
ackVersion: "0.15.0"
|
||||
project:
|
||||
name: "notes-web"
|
||||
repoPath: "/home/dev/notes-web"
|
||||
baseUrl: "http://localhost:5173"
|
||||
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
deliveryFile: "docs/ack/delivery.yaml"
|
||||
@@ -104,8 +102,20 @@ tasks:
|
||||
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
|
||||
checkedBy: "test-worker-1"
|
||||
checkedAt: "2026-07-06T09:36:00+08:00"
|
||||
# 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。
|
||||
source:
|
||||
kind: "feishu-base"
|
||||
ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
recordId: "recExample"
|
||||
updatedAt: "2026-07-06T09:00:00Z"
|
||||
description: >
|
||||
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
||||
fixLogic: >
|
||||
修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。
|
||||
acceptanceCriteria:
|
||||
- "预览后确认区显示 title、修改前值、修改后值和 coverChanged"
|
||||
- "取消预览不会产生保存请求"
|
||||
- "原先确认区为空的问题不再出现"
|
||||
stepsToReproduce:
|
||||
- "打开 /fix?fileId=1"
|
||||
- "搜索并选择候选项"
|
||||
@@ -148,6 +158,19 @@ tasks:
|
||||
attemptId: "BUG-002-A2"
|
||||
result: passed
|
||||
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
|
||||
environmentIncidents:
|
||||
- id: "BUG-002-ENV-1"
|
||||
attemptId: "BUG-002-A2"
|
||||
role: test
|
||||
phase: service
|
||||
status: resolved
|
||||
summary: "第一次复测连接到旧构建"
|
||||
evidence: "服务进程的 worktree 与当前任务不一致"
|
||||
impact: "该次执行没有形成产品通过或失败结论,不计复验轮次"
|
||||
recoveryAction: "重启当前 worktree 的最新服务后重新复验"
|
||||
userAction: "无需操作;Coordinator 已完成服务对齐"
|
||||
reportedAt: "2026-07-06T09:30:00+08:00"
|
||||
resolvedAt: "2026-07-06T09:32:00+08:00"
|
||||
resolution:
|
||||
fixedBy: "dev-worker-1"
|
||||
verifiedBy: "test-worker-1"
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||
allowlist,默认 profile 与角色/档位一致。
|
||||
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
||||
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
||||
`--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 与
|
||||
`allowedWorktrees` 共同约束。
|
||||
|
||||
## 路径权限
|
||||
|
||||
@@ -85,8 +88,9 @@
|
||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
||||
Orca live metadata 当作自动复用授权。
|
||||
- [ ] 只考虑同轮、空闲、角色/profile/worktree/启动身份匹配的复用候选;历史消息必须
|
||||
由受信后端清理并返回新会话身份。任一条件不符或无法确认清理成功时启动 fresh
|
||||
worker;没有把历史 receipt checksum 或 Orca live metadata 当作复用授权。
|
||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||
@@ -100,6 +104,8 @@
|
||||
- [ ] 每轮写入 `dispatch.rounds`。
|
||||
- [ ] 三轮失败后标记 `leftover` 并填写原因。
|
||||
- [ ] `leftover` 不阻塞其它任务。
|
||||
- [ ] Coordinator 标记整轮完成后,已关闭所有只属于 `verified` 任务的 worker;仍关联
|
||||
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
|
||||
|
||||
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
||||
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
||||
|
||||
@@ -36,24 +36,27 @@ Coordinator 发现或读取 open 任务
|
||||
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
|
||||
-> 运行 launcher plan 并审阅 launchFingerprint
|
||||
-> 带 expected fingerprint 启动 fresh worker,把 receipt 写回 tasks.yaml(见 orca-adapter.md)
|
||||
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
|
||||
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md)
|
||||
-> dispatch 给 Developer(--to <worker handle>)
|
||||
-> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||
-> writeback fixed_by_dev
|
||||
-> 为 Test 独立解析安全 profile,重新 plan/launch fresh worker 并写回 receipt
|
||||
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
||||
-> dispatch 给 Test(retesting)
|
||||
-> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
||||
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
||||
-> 通过 gate:writeback verified
|
||||
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
|
||||
-> Test 失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮)
|
||||
-> 累计三轮失败:writeback leftover,继续下一个任务
|
||||
-> Test 因环境无法完成:记录 environmentIncidents,不计轮次;有界恢复并明确告知用户下一步
|
||||
-> Test 观察到产品失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮有效产品复验)
|
||||
-> 累计三轮有效产品失败:writeback leftover,继续下一个任务
|
||||
```
|
||||
|
||||
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
||||
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
|
||||
`orca-adapter.md`。v0.10 不自动复用历史 receipt 或既有终端;标题、preview、分支名、
|
||||
worker 自报和无密钥 receipt checksum 都不能代替可信启动 attestation。
|
||||
`orca-adapter.md`。历史 receipt、标题、preview、分支名、worker 自报和无密钥 receipt
|
||||
checksum 都不能代替可信启动 attestation 或历史消息清理证明。只复用同一轮内明确空闲、
|
||||
身份完全匹配且得到新会话身份的 worker;清理能力缺失或结果不确定时必须 fresh launch。
|
||||
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
|
||||
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和
|
||||
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
|
||||
@@ -106,10 +109,10 @@ Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择
|
||||
|
||||
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
||||
|
||||
**方式 1:当前 worktree 起 fresh 子 agent(终端)**
|
||||
**方式 1:当前 worktree 复用安全重置的空闲子 agent,或起 fresh 子 agent(终端)**
|
||||
|
||||
```bash
|
||||
# v0.10 不自动复用既有终端;先审阅 plan:
|
||||
# 当前 Orca 不能可信清理历史消息,因此先审阅 fresh launch plan:
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||
--project-root <project-root> --task-id <task-id> \
|
||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
||||
@@ -189,7 +192,9 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
|
||||
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
|
||||
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
|
||||
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
|
||||
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
||||
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
|
||||
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
|
||||
不派 Developer、不消耗产品轮次。
|
||||
|
||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||
@@ -297,3 +302,32 @@ resolution:
|
||||
evidence:
|
||||
final: "<latest failing evidence>"
|
||||
```
|
||||
|
||||
环境失败不进入上面的 `rounds`,单独记录:
|
||||
|
||||
```yaml
|
||||
status: fixed_by_dev
|
||||
dispatch:
|
||||
environmentIncidents:
|
||||
- id: "BUG-001-ENV-1"
|
||||
role: test
|
||||
phase: service
|
||||
status: open
|
||||
summary: "无法访问待测服务"
|
||||
evidence: "preflight connection refused"
|
||||
impact: "未执行产品验收信号"
|
||||
recoveryAction: "启动正确 worktree 的最新服务后重试"
|
||||
userAction: "无需操作;Coordinator 将重启服务"
|
||||
reportedAt: "<timestamp>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Worker 收尾
|
||||
|
||||
Coordinator 写入本轮全部最终状态并完成已确认的 delivery 停止点后,再回收 worker。
|
||||
按 terminal handle 聚合所有 receipt 引用:只关闭全部关联任务均为 `verified`、且没有
|
||||
未解决环境事件的 handle。任何关联任务仍为 `open`、`dispatched`、`fixed_by_dev`、
|
||||
`retesting`、`blocked`、`failed_retest`、`leftover` 或状态不明时都保留该终端,不设置
|
||||
TTL。关闭前后按 `orca-adapter.md` 核对 runtime/handle/incarnation 和关闭回执;关闭
|
||||
不确定时报告并保留审计记录。
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# ACK 交付阶段
|
||||
|
||||
本文件定义可选的 `verified -> review_ready/released` 交付阶段。开发、独立复测和
|
||||
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
|
||||
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
||||
交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入
|
||||
`docs/ack/tasks.yaml.deliveryRuns`。
|
||||
|
||||
## 1. 配置与授权不是一回事
|
||||
|
||||
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。ACK 在
|
||||
kickoff 的既有用户确认点同时展示本次 profile、remote、产物目标、环境和停止点;用户
|
||||
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。启用交付
|
||||
时,ACK 在 kickoff 的既有用户确认点默认展示 `defaultProfile`、remote、产物目标、环境
|
||||
和停止点,不得静默省略;用户
|
||||
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
|
||||
channel、environment 或 source revision 漂移时重新确认。
|
||||
|
||||
@@ -42,8 +43,9 @@ channel、environment 或 source revision 漂移时重新确认。
|
||||
stable。
|
||||
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
|
||||
deploy 和 health check,production 还必须声明 rollback。
|
||||
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `review_ready`,不能
|
||||
发布 stable 或部署 production。
|
||||
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `validation_ready` 或
|
||||
`review_ready`,不能发布 stable 或部署 production。`validation_ready` profile 必须
|
||||
至少部署一个 development/staging 环境,并对每个部署目标执行健康检查。
|
||||
|
||||
配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径
|
||||
或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部
|
||||
@@ -100,7 +102,9 @@ channel、environment 或 source revision 漂移时重新确认。
|
||||
## 6. 状态与恢复
|
||||
|
||||
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
|
||||
`blocked`、`failed`、`review_ready`、`released` 或 `skipped`。部署或 Forge 暂时失败不把
|
||||
`blocked`、`failed`、`validation_ready`、`review_ready`、`released` 或 `skipped`。
|
||||
`validation_ready` 表示已把不可变产物部署到开发/测试环境并通过健康检查,可以交给
|
||||
用户手工验证,不要求存在 PR/MR。部署或 Forge 暂时失败不把
|
||||
任务改回 `failed_retest`。
|
||||
|
||||
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
|
||||
@@ -110,7 +114,8 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
||||
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
|
||||
时,才可从失败步骤继续。
|
||||
|
||||
`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
||||
`validation_ready` 至少记录:source/config revision、所有产物引用与 digest、部署环境、
|
||||
访问地址和用户下一步,以及健康检查证据。`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
||||
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
|
||||
“完成”掩盖其中某一阶段失败或待审批。
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# 飞书 Base Bug 整理与审核
|
||||
|
||||
飞书 Base 是 Bug 在审核通过前的唯一协作区。用户只需填写 `标题`,可选填写
|
||||
`详细描述`,并把截图、录屏或日志放在 `附件`。Coordinator 结合来源事实、附件和项目
|
||||
现状整理 `问题说明`、`期望效果`、`验收标准`,但不在收件箱写修复逻辑。
|
||||
|
||||
审核通过前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker、派发 Developer/Test
|
||||
或修改应用代码。字段已填满、记录进入某个 view、用户暂时没有回复,都不等于审核通过。
|
||||
|
||||
## 项目配置与 Base 结构
|
||||
|
||||
`docs/ack/tasks.yaml` 的 `project.bugIntake` 必须声明 `provider: feishu-base`、
|
||||
`workflow: clarified-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、`viewId` 和字段映射:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
title: 标题
|
||||
details: 详细描述
|
||||
problemStatement: 问题说明
|
||||
expectedOutcome: 期望效果
|
||||
acceptance: 验收标准
|
||||
intakeStatus: 处理状态
|
||||
ackTaskId: ACK任务ID
|
||||
attachments: 附件
|
||||
updatedAt: 更新时间
|
||||
```
|
||||
|
||||
人维护 `标题`、`详细描述`、`附件`;ACK 维护 `问题说明`、`期望效果`、`验收标准`;系统字段
|
||||
是 `处理状态`、`ACK任务ID`、`更新时间`。状态按
|
||||
`待整理 → 需补充/待审核 → 已确认 → 已导入` 流转。优先级在批准后做任务规划时确定,
|
||||
不属于收件箱审核内容。原 `read-only-v1` 和 `reviewed-writeback-v1` 继续兼容旧项目。
|
||||
|
||||
结构变更必须先执行只读计划,再携带计划指纹应用;适配器只新增目标字段、迁移来源信息并
|
||||
调整当前 view 的可见字段,不删除旧列:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-plan docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-apply docs/ack/tasks.yaml \
|
||||
--expected-schema-fingerprint <schemaFingerprint>
|
||||
```
|
||||
|
||||
迁移时,旧 `期望结果` 会以 `用户原始期望:…` 合并进 `详细描述`,旧字段继续保留但从
|
||||
当前 view 隐藏;空状态初始化为 `待整理`。不要保存 App Secret、access token 或 profile
|
||||
凭据。
|
||||
|
||||
## Profile 和安全边界
|
||||
|
||||
在账号级可信目录安装官方 CLI:
|
||||
|
||||
```bash
|
||||
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
||||
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
|
||||
--name project-feishu --app-id "$FEISHU_APP_ID" \
|
||||
--app-secret-stdin --brand feishu
|
||||
```
|
||||
|
||||
使用官方 `lark-cli`,项目只保存 profile 名。不要依赖 active profile;所有读取、附件下载、
|
||||
写回和结构迁移都显式传配置 profile。profile 需要 Base 读写和附件下载权限。
|
||||
所需 scope 至少包括 `base:record:read`、`base:record:write` 和
|
||||
`docs:document.media:download`。不要把 `lark-cli auth check` 当作 app/bot scope 的证明,
|
||||
它只检查当前用户的 stored user token。
|
||||
|
||||
适配器只搜索账号和系统的可信工具目录,子进程只收到实际账号 HOME、可信 PATH 和基础
|
||||
locale;调用者环境中的凭据和运行时注入变量不会传入。不得绕过适配器直接操作 Base。
|
||||
每条记录最多 10 个附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,
|
||||
整批下载最多 5 分钟。
|
||||
|
||||
## 读取与整理
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
|
||||
tmpdir=$(mktemp -d)
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch docs/ack/tasks.yaml \
|
||||
--output-dir "$tmpdir"
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \
|
||||
--output-dir "$tmpdir"
|
||||
```
|
||||
|
||||
`fetch` 输出标准化 JSON,并为每条记录计算覆盖来源事实、附件身份、整理字段和状态的
|
||||
`draftRevision`。标题和更新时间是必需来源事实;详细描述和附件可为空。问题说明、期望
|
||||
效果、验收标准缺失时返回 `enrichmentRequired`。`plan` 只给出查重和漂移预览,审核前
|
||||
不得据此创建任务。
|
||||
|
||||
Coordinator 对每条 Bug:
|
||||
|
||||
1. 读取标题、详细描述、附件及相关产品/代码上下文;证据不足时明确假设,不伪装成用户原文。
|
||||
2. 写 `problemStatement`:说清现象、影响范围和边界,不包含修复方案。
|
||||
3. 写 `expectedOutcome`:说明正确情况下用户能观察到的行为,不包含实现方式。
|
||||
4. 写 `acceptance`:形成可独立复测的、可观察的标准,不扩张用户未表达的产品范围。
|
||||
5. 将草案保存为不超过 64 KiB 的 JSON,且只含上述三个键:
|
||||
|
||||
```json
|
||||
{
|
||||
"problemStatement": "非空字符串",
|
||||
"expectedOutcome": "非空字符串",
|
||||
"acceptance": ["非空验收项"]
|
||||
}
|
||||
```
|
||||
|
||||
使用当前 `sourceRef` 和 `draftRevision` 写回:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
||||
docs/ack/tasks.yaml --record-id <record-id> \
|
||||
--expected-source-ref <sourceRef> \
|
||||
--expected-draft-revision <draftRevision> --input <draft.json>
|
||||
```
|
||||
|
||||
适配器在写前检查记录仍位于配置 view 且 revision 未漂移,只覆盖问题说明、期望效果、
|
||||
验收标准和处理状态(设为 `待审核`),随后回读并返回新 revision。把新 revision 连同整理
|
||||
结果交给用户审核。用户反馈后重新读取、修订和写回;不要在聊天或本地维护分叉版本。
|
||||
|
||||
## 审核门禁与导入
|
||||
|
||||
只有用户针对当前 revision 明确表示审核通过,并由用户本人在飞书把处理状态改为
|
||||
`已确认`,才可继续导入。Coordinator 使用的适配器不提供把草案自行标成已确认的命令;
|
||||
状态和任务 ID 不参与内容 revision,因此用户确认状态不会改变已批准内容的 revision。
|
||||
|
||||
重新读取并核对状态和 revision 后执行:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
||||
docs/ack/tasks.yaml --record-id <record-id> \
|
||||
--expected-source-ref <approved-sourceRef> \
|
||||
--expected-draft-revision <approvedDraftRevision>
|
||||
```
|
||||
|
||||
任一来源事实、附件或整理字段变化都会使旧批准失效。导出的 `taskDraft` 把问题说明映射为
|
||||
`description`、期望效果映射为 `expected`、验收标准映射为 `acceptanceCriteria`;不包含
|
||||
修复逻辑、复现步骤或优先级。Coordinator 在后续任务规划中补充优先级,但不得改写已审核
|
||||
字段;校验器会重算 `approvedPayloadHash`。
|
||||
|
||||
把 `taskDraft` 写入 `tasks.yaml`、补齐任务 ID 和规划字段并通过任务板校验后,再把最终
|
||||
任务 ID 与同一批准 revision 写回飞书:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py mark-imported \
|
||||
docs/ack/tasks.yaml --record-id <record-id> --task-id <ack-task-id> \
|
||||
--expected-source-ref <approved-sourceRef> \
|
||||
--expected-draft-revision <approvedDraftRevision>
|
||||
```
|
||||
|
||||
适配器只在任务板恰有一条 ID 匹配、来源引用、记录 ID、批准 revision 和 payload hash
|
||||
全部一致的任务时,写入 `ACK任务ID` 并把状态推进到 `已导入`;回读不一致则失败。重复执行
|
||||
同一绑定是幂等的,不能把一条记录改挂到另一个任务。
|
||||
|
||||
按 `source.ref` 查重:仅未派发的 `open` 任务可刷新;其它状态只报告来源漂移,不覆盖。
|
||||
来源消失、不可访问或同步失败时,不删除已有 ACK 任务。
|
||||
@@ -83,11 +83,13 @@ docs/ack/
|
||||
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
||||
自动补交付配置。
|
||||
- `updatedAt` 使用当前带时区时间。
|
||||
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
||||
真实值。
|
||||
- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。
|
||||
ACK 从命令行 `--project-root` 下固定的 `docs/ack/` 布局解析项目状态,不把
|
||||
`repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取,
|
||||
不再参与路径绑定。
|
||||
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
||||
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worker worktree;模型
|
||||
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
||||
`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
||||
@@ -143,7 +145,7 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||
v0.10 不自动复用旧终端。
|
||||
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
|
||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
我要做一个新需求:<一句话需求>。
|
||||
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
|
||||
|
||||
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
||||
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
||||
1. 先读 docs/ack/project.md,并用 `scripts/select_tasks.py docs/ack/tasks.yaml`
|
||||
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
|
||||
完整 tasks.yaml 注入上下文。校验 docs/ack/knowledge.yaml 并用
|
||||
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
||||
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
||||
如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与
|
||||
references/delivery.md,但不要把配置本身当作执行授权。
|
||||
@@ -32,11 +34,15 @@
|
||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
||||
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
||||
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
||||
fingerprint 创建 fresh worker;
|
||||
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
||||
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
||||
记录、恢复并告诉我下一步,不占产品复验轮次。
|
||||
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
||||
deliveryRuns;默认停在 review_ready,stable/production 步骤再次向我确认。
|
||||
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
||||
或 review_ready,stable/production 步骤再次向我确认。
|
||||
```
|
||||
|
||||
---
|
||||
@@ -58,6 +64,17 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
||||
`project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、
|
||||
不匹配的角色/档位和非安全权限必须在派发前 fail closed。
|
||||
|
||||
读取任务上下文使用:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
||||
--task-id <ack-task-id>
|
||||
```
|
||||
|
||||
选择器解析并校验完整任务板,但只输出命中任务及其 receipt/delivery run;超过预算时
|
||||
显式失败,不得改用 `cat` 或整文件 `sed`。
|
||||
|
||||
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
||||
|
||||
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
|
||||
@@ -76,9 +93,11 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
||||
|
||||
## 第 3 步:审阅并启动 worker
|
||||
|
||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。v0.10 不把历史 receipt
|
||||
或既有 Orca terminal 当作自动复用授权;每次自动派发都创建 fresh worker。原因和
|
||||
边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。历史 receipt 或既有 Orca
|
||||
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
|
||||
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
|
||||
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
|
||||
原因和边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
||||
|
||||
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
||||
不能用于匹配或复用旧 receipt / 既有终端:
|
||||
@@ -115,7 +134,7 @@ receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该
|
||||
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
|
||||
dispatch。后续
|
||||
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
|
||||
receipt 仅用于审计,不能在后续轮次自动复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||
receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||
底层创建命令。
|
||||
|
||||
新 worktree 时可先运行
|
||||
@@ -133,12 +152,13 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调
|
||||
|
||||
```text
|
||||
task-create → dispatch 给 DEV → 等 worker_done
|
||||
→ 每个角色都通过 plan + expected fingerprint launch fresh worker 并写回 receipt
|
||||
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
||||
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
||||
→ 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result
|
||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||
→ Coordinator 读证据终检 → 过则 verified,不过则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||
→ 三轮失败:leftover,升级复盘,继续下一个
|
||||
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
||||
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
|
||||
```
|
||||
|
||||
具体命令见 `orca-adapter.md`(Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`。
|
||||
@@ -154,15 +174,22 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
||||
## 第 5 步:可选交付
|
||||
|
||||
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
||||
`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和
|
||||
`delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`,
|
||||
用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和
|
||||
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
|
||||
`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和
|
||||
production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把
|
||||
`tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。
|
||||
前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在
|
||||
approval 步骤再次确认。失败时保留任务的 `verified`,把
|
||||
delivery run 标为 `blocked` 或 `failed`。
|
||||
|
||||
## 第 6 步:收尾
|
||||
|
||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:
|
||||
哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||
Coordinator 最后标记整轮任务完成后,关闭所有只关联 `verified` 任务的 Developer/Test
|
||||
终端并核对关闭回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、
|
||||
`leftover` 或其它未完成任务/未解决环境事件的终端保留,不设置 TTL。一个终端只要仍被
|
||||
任一这类任务引用,就不能因为同时关联了 verified 任务而关闭。
|
||||
|
||||
---
|
||||
|
||||
@@ -172,4 +199,5 @@ delivery run 标为 `blocked` 或 `failed`。
|
||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
||||
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
||||
跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收仅属于 verified
|
||||
任务的 worker,保留 blocked/failed/leftover worker。
|
||||
|
||||
@@ -57,9 +57,8 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),
|
||||
- 低档位反复产出表面修复。
|
||||
|
||||
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
|
||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。
|
||||
v0.10 不自动复用持久化 receipt 指向的旧终端,Test 也不得使用 Developer 的强档
|
||||
worker。
|
||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。模型或
|
||||
profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强档 worker。
|
||||
|
||||
---
|
||||
|
||||
@@ -127,6 +126,11 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中
|
||||
`project.orchestration.allowedWorktrees`。
|
||||
|
||||
项目状态文件固定从 `--project-root/docs/ack/` 解析;任务板不需要保存 `repoPath` 或
|
||||
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
|
||||
`projectRoot`、任务板内容摘要和 worker worktree identity 都会进入 launch fingerprint,
|
||||
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
|
||||
|
||||
- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。
|
||||
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
|
||||
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
|
||||
@@ -206,10 +210,13 @@ receipt 至少把以下事实绑定在一起:
|
||||
|
||||
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
|
||||
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
|
||||
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 v0.10
|
||||
明确禁止根据持久化 receipt 自动复用既有终端:每次需要自动派发 worker,都重新走
|
||||
`plan` → 带 expected fingerprint 的 `launch`,只使用该次 launcher 标准输出中的
|
||||
fresh handle 完成本次派发。
|
||||
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 ACK
|
||||
明确禁止根据持久化 receipt 自动复用既有终端。复用只允许发生在同一轮 ACK 内,并且
|
||||
必须先证明 worker 空闲、角色/profile/worktree/runtime/incarnation 完全匹配,再由
|
||||
受信后端清理历史消息并返回新的 conversation/session identity 与本次 task/attempt
|
||||
绑定。正在工作、等待回报、状态不明或关联未完成任务的 worker 都不是空闲候选。任一
|
||||
条件不满足、清理失败或清理结果无法确认时,重新走 `plan` → 带 expected fingerprint
|
||||
的 `launch`,使用 fresh handle 派发。
|
||||
|
||||
`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份
|
||||
计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint
|
||||
@@ -218,9 +225,10 @@ fresh handle 完成本次派发。
|
||||
提供,而不是把 checksum 冒充成一次性令牌。
|
||||
|
||||
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
|
||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。未来只有
|
||||
在 Orca/ACP 提供启动参数 attestation,或存在项目外可信签发与校验通道后,才开放
|
||||
自动复用。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。只有
|
||||
Orca/ACP 同时提供启动参数 attestation、明确空闲状态、可信历史清理和新会话身份,或
|
||||
ACK 接入等价的项目外可信签发与校验通道,才实际启用自动复用。当前 Orca 不满足这些
|
||||
条件,所以仍创建 fresh worker。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||
|
||||
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
|
||||
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
|
||||
|
||||
@@ -62,7 +62,49 @@ Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它
|
||||
|
||||
---
|
||||
|
||||
## 4. 三轮失败策略(SSOT)
|
||||
## 4. 有效复验、环境失败与三轮策略(SSOT)
|
||||
|
||||
### 4.1 什么才计算一轮
|
||||
|
||||
三轮预算只计算**有效产品复验**:Test 已确认正确 worktree、最新服务、必要测试数据和
|
||||
可用验证工具,并实际执行目标验收信号;结果要么全部通过,要么观察到由待测产品行为
|
||||
导致的信号失败。
|
||||
|
||||
以下情况属于环境失败,不是产品失败,也不占复验轮次:worker 未启动或消息未投递、
|
||||
sandbox/权限阻止访问待测服务、服务实例或构建不匹配、必要测试数据缺失、浏览器或测试
|
||||
工具不可用、编排 IPC 失败。若已有独立的产品信号明确失败,只把该产品失败计入轮次;
|
||||
其余环境问题另行记录,不能用“环境失败”掩盖产品证据。
|
||||
|
||||
环境失败写入 `dispatch.environmentIncidents`,不要追加到 `dispatch.rounds`,也不要把
|
||||
任务写成 `failed_retest`。实现已经完成时保持 `fixed_by_dev`;恢复后再进入
|
||||
`retesting`。确实需要用户或外部条件才能继续时可暂时写 `blocked`,环境恢复后回到
|
||||
原闭环状态。
|
||||
|
||||
每条环境事件必须包含:
|
||||
|
||||
```yaml
|
||||
dispatch:
|
||||
environmentIncidents:
|
||||
- id: "BUG-001-ENV-1"
|
||||
attemptId: "BUG-001-A1"
|
||||
role: test
|
||||
phase: browser
|
||||
status: resolved
|
||||
summary: "测试环境没有可用浏览器"
|
||||
evidence: "chromium/playwright lookup 均为空"
|
||||
impact: "没有执行点击级验收,不能据此判断产品失败"
|
||||
recoveryAction: "改用受支持的浏览器运行时并启动 fresh Test"
|
||||
userAction: "无需操作;Coordinator 继续恢复"
|
||||
reportedAt: "<timestamp>"
|
||||
resolvedAt: "<timestamp>"
|
||||
```
|
||||
|
||||
`userAction` 必须明确:无需用户操作时写清 Coordinator 下一步;需要用户介入时给出一个
|
||||
具体决定、命令或外部条件,不能只写“请处理环境”。Coordinator 可以先做一次不扩大权限、
|
||||
不改变产品数据的有界恢复;仍未解决、需要用户动作或阻断本轮时,在当前会话立即报告。
|
||||
即使事件已自动恢复,最终报告也必须列出环境事件、影响和恢复结果,让用户知道发生过什么。
|
||||
|
||||
### 4.2 三轮有效产品失败
|
||||
|
||||
每个任务最多自动派发三轮:
|
||||
|
||||
@@ -73,7 +115,9 @@ round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
|
||||
failed after round 3: 标记 leftover,继续下一个任务
|
||||
```
|
||||
|
||||
三轮失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、Worker 对问题模型理解错了、UI 自动化与实际浏览器状态有差异、需要人工观察或调试工具介入。
|
||||
三轮有效产品失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、
|
||||
Worker 对问题模型理解错了,或需要人工观察和专项调试。环境事件数量不受三轮预算限制,
|
||||
但必须有界恢复和透明报告,不能无限重试。
|
||||
|
||||
留档字段(结构见 `templates/tasks.schema.json`):
|
||||
|
||||
@@ -180,6 +224,7 @@ Developer 回报实际采用的 `knowledgeApplied` 和带当前观测证据的
|
||||
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
|
||||
- 当前工作树有哪些未提交改动?
|
||||
- 是否还有 open / failed_retest 未处理?
|
||||
- 本轮有哪些环境事件?是否已解决?用户下一步是“无需操作”还是一个明确动作?
|
||||
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`?
|
||||
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
|
||||
|
||||
|
||||
@@ -24,27 +24,39 @@ orca orchestration inbox --limit 20 --json
|
||||
```
|
||||
|
||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
|
||||
的活跃编排任务。Developer/Test worker 先按下节检查安全复用候选;当前 Orca 无可信
|
||||
历史清理能力时必须通过本次 launcher 新建。
|
||||
|
||||
---
|
||||
|
||||
## 既有 worker 的处理
|
||||
|
||||
v0.10 **不自动复用任何既有终端**。`receiptHash` 是无密钥 checksum,Orca 当前的
|
||||
terminal metadata 也不包含原始 Agent argv、模型或权限 attestation;两者即使完全
|
||||
匹配,仍不足以证明旧终端由可信 launcher 以目标 profile 创建。标题、preview、分支
|
||||
名或 worker 自报更不能作为证明。
|
||||
先找同一轮 ACK 内可复用的空闲 worker,再决定是否创建。候选必须同时满足:
|
||||
|
||||
- Orca 明确报告终端存活且空闲;不得复用仍在工作、等待 `worker_done` / 复测报告 /
|
||||
escalation,或运行状态不明的 worker;
|
||||
- 没有关联 `blocked`、`failed_retest`、`leftover`、未解决环境事件或其它未完成任务;
|
||||
- 角色、profile、worktree、runtime/incarnation 与本次目标完全匹配,Developer 与 Test
|
||||
永不互相复用;
|
||||
- 受信后端能够清理历史消息,并返回可核对的新 conversation/session identity 和本次
|
||||
task/attempt 绑定;只有看到成功回执后才把 worker 视为已重置。
|
||||
|
||||
`receiptHash` 是无密钥 checksum,标题、preview、分支名、worker 自报或普通 Orca
|
||||
terminal metadata 都不能证明历史上下文已清理。任何条件不符、清理接口不存在、清理
|
||||
失败或结果无法确认时,都重新 `plan`/`launch` fresh worker。
|
||||
|
||||
| 情况 | 动作 |
|
||||
|------|------|
|
||||
| 历史 receipt 与 live metadata 看似完全匹配 | 只作审计,不自动复用;重新 launch |
|
||||
| 没有 receipt,或只有同名终端 | 不复用;重新 launch |
|
||||
| 同轮空闲,身份完全匹配,历史消息清理得到可信回执 | 生成新的 task/attempt 绑定后复用 |
|
||||
| worker 正在工作、等待回报、状态不明或关联未完成任务 | 不复用;保留原终端并重新 launch |
|
||||
| profile、角色、worktree、runtime/incarnation 任一不匹配 | 不复用;重新 launch |
|
||||
| 无历史消息清理能力,或无法确认清理成功 | 不复用;重新 launch |
|
||||
| 需要并发 worker | 使用下一 `slot` 重新 launch |
|
||||
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
|
||||
|
||||
只有刚刚成功返回的 `launch` 标准输出中的 fresh handle,才可用于紧随其后的本次
|
||||
dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项目外可信签发
|
||||
通道,再单独设计自动复用;不能用项目文档声明跳过这条限制。
|
||||
当前 Orca terminal API 没有返回新会话身份的可信历史消息清理操作,因此当前适配器
|
||||
不会复用 Orca worker;仍只使用刚刚成功返回的 `launch` fresh handle。以后后端补齐
|
||||
reset/attestation 后才能启用上述复用路径,不能用项目文档声明跳过条件。
|
||||
|
||||
---
|
||||
|
||||
@@ -114,8 +126,9 @@ Developer: owns implementation and white-box verification.
|
||||
Test: owns independent black-box retest and evidence (verifier != implementer).
|
||||
|
||||
Policy:
|
||||
- Each issue can be dispatched at most 3 rounds.
|
||||
- If still failing after 3 rounds, record as leftover and continue next issue.
|
||||
- Each issue can have at most 3 valid product-retest rounds.
|
||||
- Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round.
|
||||
- If still failing after 3 valid product rounds, record as leftover and continue next issue.
|
||||
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml and knowledge.yaml.
|
||||
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
|
||||
EOF
|
||||
@@ -274,4 +287,26 @@ orca orchestration send \
|
||||
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
||||
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
|
||||
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
|
||||
写 `verified`,不过写 `failed_retest`。
|
||||
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
|
||||
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
|
||||
|
||||
---
|
||||
|
||||
## 整轮结束时回收 worker
|
||||
|
||||
只有 Coordinator 已把本轮最终结论写入 `tasks.yaml` 后才开始回收。先按 receipt 的
|
||||
`binding.handle` 聚合任务引用:只有一个 handle 关联的全部任务都为 `verified`,且没有
|
||||
未解决环境事件,才允许关闭。`open`、`dispatched`、`fixed_by_dev`、`retesting`、
|
||||
`blocked`、`failed_retest`、`leftover` 以及状态不明任务关联的 handle 全部保留,不设置
|
||||
TTL。普通用户 Shell 和 Coordinator 自己的终端不在回收范围。
|
||||
|
||||
逐个回收时:
|
||||
|
||||
1. `orca terminal show --terminal <handle> --json`,把 runtimeId、handle、incarnationId
|
||||
和 worktree 与 receipt 逐项核对;不匹配就停止并报告。
|
||||
2. `orca terminal close --terminal <handle> --tab --json`。
|
||||
3. 核对关闭回执的 runtimeId、handle、tab 和关闭模式,再确认终端不在 live list。
|
||||
4. 关闭结果不确定时保留记录并报告,禁止盲目重试或标记为已回收。
|
||||
|
||||
关闭终端不删除 `workerReceipts`、dispatch 证据或测试记录;这些仍是审计事实。保留下来
|
||||
的 blocked/failed worker 只在后续任务得到验证或用户明确要求清理时回收。
|
||||
|
||||
@@ -134,6 +134,9 @@ Developer 本轮声称(仅供参考,不作数):
|
||||
1. <observable signal 1>
|
||||
2. <observable signal 2>
|
||||
3. <observable signal 3>
|
||||
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
||||
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
||||
证据,则分别列出产品信号与环境限制。
|
||||
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
||||
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||
@@ -227,6 +230,7 @@ knowledgeCandidates:
|
||||
rationale: <why this changes future work>
|
||||
evidenceRefs: [<independent evidence reference>]
|
||||
conclusion: all-signals-pass / signals-failed
|
||||
environmentFailure: null / <phase, evidence, impact, recovery suggestion>
|
||||
notes: <observations, suspected cause, and independent candidate validation evidence>
|
||||
```
|
||||
|
||||
@@ -245,6 +249,9 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
||||
已验证(Test 复测通过 + 终检):
|
||||
- <task_id>: <what passed>
|
||||
|
||||
环境事件:
|
||||
- <task_id>: <原因、影响、是否恢复、Coordinator 下一步、userAction>
|
||||
|
||||
遗留:
|
||||
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
|
||||
|
||||
@@ -257,7 +264,7 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
||||
- 待验证 candidate:<remaining candidates or none>
|
||||
|
||||
交付(未启用时写 n/a):
|
||||
- run/profile/status:<delivery run id / profile / review_ready|released|blocked|failed>
|
||||
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
||||
- PR/MR:<URL and head/base>
|
||||
- 产物:<reference, digest, destination>
|
||||
- 部署:<environment, health/rollback evidence>
|
||||
|
||||
@@ -45,7 +45,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
||||
`dispatchId` 代替。
|
||||
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks` 和
|
||||
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
|
||||
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮。
|
||||
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮有效
|
||||
产品复验。环境失败单独记录、恢复和报告,不占轮次。
|
||||
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
|
||||
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行。
|
||||
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
|
||||
@@ -131,6 +132,16 @@ retesting -> failed_retest -> dispatched
|
||||
failed_retest(累计 3 轮) -> leftover
|
||||
```
|
||||
|
||||
环境分支不进入 `failed_retest`:
|
||||
|
||||
```text
|
||||
launch / service / data / browser / tooling environment failure
|
||||
-> dispatch.environmentIncidents
|
||||
-> 有界恢复并告知用户下一步
|
||||
-> fixed_by_dev(待恢复)或 blocked(需外部动作)
|
||||
-> 环境恢复后 retesting
|
||||
```
|
||||
|
||||
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
|
||||
|
||||
| 状态 | 依据来源 | 含义 |
|
||||
@@ -139,12 +150,12 @@ failed_retest(累计 3 轮) -> leftover
|
||||
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
|
||||
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
|
||||
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
|
||||
| `failed_retest` | Test 的复测报告 | 复测失败,可继续派发 Developer |
|
||||
| `failed_retest` | Test 的产品复测报告 | 环境对齐后观察到产品验收失败,可继续派发 Developer |
|
||||
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
|
||||
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
|
||||
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
|
||||
|
||||
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
||||
有效复验与环境失败的处理细则见 `optimization-method.md` §4。
|
||||
|
||||
## 交付状态(与任务状态正交)
|
||||
|
||||
@@ -152,11 +163,12 @@ failed_retest(累计 3 轮) -> leftover
|
||||
`tasks.yaml.deliveryRuns`:
|
||||
|
||||
```text
|
||||
planned -> running -> review_ready | released
|
||||
planned -> running -> validation_ready | review_ready | released
|
||||
-> blocked | failed
|
||||
planned -> skipped
|
||||
```
|
||||
|
||||
`validation_ready` 表示开发/测试环境已部署且健康检查通过,等待用户手工验证;
|
||||
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
|
||||
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
|
||||
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
参考项目1
|
||||
|
||||
[https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team](https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team)
|
||||
|
||||
参考项目2
|
||||
|
||||
[https://github.com/DannyMac180/sol-advisor](https://github.com/DannyMac180/sol-advisor)
|
||||
|
||||
参考项目3
|
||||
|
||||
[https://cursor.com/cn/blog/agent-swarm-model-economics](https://cursor.com/cn/blog/agent-swarm-model-economics)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Canonical reviewed Bug task payload shared by intake and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
LEGACY_PAYLOAD_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"priority",
|
||||
"actual",
|
||||
"expected",
|
||||
"stepsToReproduce",
|
||||
"fixLogic",
|
||||
"acceptanceCriteria",
|
||||
)
|
||||
CLARIFIED_PAYLOAD_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"actual",
|
||||
"expected",
|
||||
"acceptanceCriteria",
|
||||
)
|
||||
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
|
||||
|
||||
|
||||
def review_items(value: str) -> list[str]:
|
||||
"""Recover line or numbered-list review text as stable non-empty items."""
|
||||
lines = [line.strip(" \t-*•") for line in value.splitlines() if line.strip()]
|
||||
if len(lines) > 1:
|
||||
return lines
|
||||
text = value.strip()
|
||||
matches = list(NUMBERED_ITEM.finditer(text))
|
||||
if matches:
|
||||
items: list[str] = []
|
||||
for index, match in enumerate(matches):
|
||||
start = match.end()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
item = text[start:end].strip()
|
||||
if item:
|
||||
items.append(item)
|
||||
if items:
|
||||
return items
|
||||
return [text] if text else []
|
||||
|
||||
|
||||
def approval_payload_hash(task: dict[str, Any]) -> str:
|
||||
"""Hash the exact reviewed fields that Developer and Test consume."""
|
||||
source = task.get("source")
|
||||
workflow = source.get("workflow") if isinstance(source, dict) else None
|
||||
fields = (
|
||||
CLARIFIED_PAYLOAD_FIELDS
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else LEGACY_PAYLOAD_FIELDS
|
||||
)
|
||||
payload = {field: task.get(field) for field in fields}
|
||||
encoded = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -260,16 +260,6 @@ def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]:
|
||||
if errors:
|
||||
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
|
||||
raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}")
|
||||
|
||||
project = board.get("project")
|
||||
repo_path = project.get("repoPath") if isinstance(project, dict) else None
|
||||
if not isinstance(repo_path, str):
|
||||
raise LaunchError("project.repoPath 必须配置为项目根绝对路径")
|
||||
configured_root = canonical_directory(repo_path, "project.repoPath")
|
||||
if configured_root != project_root:
|
||||
raise LaunchError(
|
||||
f"project.repoPath 与 --project-root 不一致: {configured_root}"
|
||||
)
|
||||
return project_root, board
|
||||
|
||||
|
||||
@@ -641,6 +631,7 @@ def build_plan(
|
||||
raise LaunchError("slot 必须是 1..99 的整数")
|
||||
|
||||
project_root, board = load_authoritative_board(project_root_value)
|
||||
board_hash = canonical_sha256(board)
|
||||
find_task(board, task_id)
|
||||
project = board["project"]
|
||||
orchestration = project.get("orchestration")
|
||||
@@ -696,6 +687,8 @@ def build_plan(
|
||||
{
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(project_root),
|
||||
"boardHash": board_hash,
|
||||
"profileId": profile_id,
|
||||
"profileHash": current_profile_hash,
|
||||
"createdFor": created_for,
|
||||
@@ -715,6 +708,7 @@ def build_plan(
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(project_root),
|
||||
"boardHash": board_hash,
|
||||
"taskId": task_id,
|
||||
"attemptId": attempt_id,
|
||||
"role": role,
|
||||
@@ -1077,6 +1071,8 @@ def build_receipt(
|
||||
"profileId": plan["profileId"],
|
||||
"profileHash": plan["profileHash"],
|
||||
"launchFingerprint": plan["launchFingerprint"],
|
||||
"projectRoot": plan["projectRoot"],
|
||||
"boardHash": plan["boardHash"],
|
||||
"slot": plan["slot"],
|
||||
"createdFor": {
|
||||
"taskId": plan["taskId"],
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 ACK Coordinator 输出有预算的任务板上下文。
|
||||
|
||||
脚本会解析并校验完整 tasks.yaml,但只输出项目配置、摘要、可工作任务或显式任务,
|
||||
以及这些任务引用的 worker receipt 和 delivery run。它不会修改任务板,也不会静默
|
||||
截断超过预算的任务集合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from validate_tasks import STATUS_ENUM, load_document, validate_builtin # type: ignore
|
||||
|
||||
|
||||
ACTIONABLE_STATUSES = (
|
||||
"open",
|
||||
"dispatched",
|
||||
"fixed_by_dev",
|
||||
"retesting",
|
||||
"failed_retest",
|
||||
)
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
MAX_OUTPUT_BYTES = 512 * 1024
|
||||
BOARD_METADATA_FIELDS = (
|
||||
"version",
|
||||
"updatedAt",
|
||||
"source",
|
||||
"ackVersion",
|
||||
"kitVersion",
|
||||
"testRecord",
|
||||
"statusReference",
|
||||
)
|
||||
|
||||
|
||||
class SelectionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _unique(values: list[str]) -> list[str]:
|
||||
return list(dict.fromkeys(values))
|
||||
|
||||
|
||||
def select_tasks(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
task_ids: list[str] | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
|
||||
raise SelectionError(f"limit 必须在 1..{MAX_LIMIT} 之间")
|
||||
tasks = data.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
raise SelectionError("tasks 必须是列表")
|
||||
|
||||
requested_ids = _unique(task_ids or [])
|
||||
requested_statuses = _unique(statuses or [])
|
||||
if requested_ids and requested_statuses:
|
||||
raise SelectionError("--task-id 与 --status 不能同时使用")
|
||||
|
||||
if requested_ids:
|
||||
by_id = {
|
||||
task.get("id"): task
|
||||
for task in tasks
|
||||
if isinstance(task, dict) and isinstance(task.get("id"), str)
|
||||
}
|
||||
missing = [task_id for task_id in requested_ids if task_id not in by_id]
|
||||
if missing:
|
||||
raise SelectionError("找不到任务: " + ", ".join(missing))
|
||||
selected = [by_id[task_id] for task_id in requested_ids]
|
||||
mode = "task_ids"
|
||||
criteria = requested_ids
|
||||
else:
|
||||
effective_statuses = requested_statuses or list(ACTIONABLE_STATUSES)
|
||||
unknown = [status for status in effective_statuses if status not in STATUS_ENUM]
|
||||
if unknown:
|
||||
raise SelectionError("未知状态: " + ", ".join(unknown))
|
||||
wanted = set(effective_statuses)
|
||||
selected = [
|
||||
task
|
||||
for task in tasks
|
||||
if isinstance(task, dict) and task.get("status") in wanted
|
||||
]
|
||||
mode = "statuses"
|
||||
criteria = effective_statuses
|
||||
|
||||
if len(selected) > limit:
|
||||
raise SelectionError(
|
||||
f"命中 {len(selected)} 条任务,超过 --limit={limit};"
|
||||
"请用 --task-id/--status 缩小范围或显式提高 limit"
|
||||
)
|
||||
return mode, criteria, selected
|
||||
|
||||
|
||||
def select_referenced_receipts(
|
||||
data: dict[str, Any], selected: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
receipt_ids: list[str] = []
|
||||
for task in selected:
|
||||
dispatch = task.get("dispatch")
|
||||
if not isinstance(dispatch, dict):
|
||||
continue
|
||||
for role in ("developer", "test"):
|
||||
role_dispatch = dispatch.get(role)
|
||||
if not isinstance(role_dispatch, dict):
|
||||
continue
|
||||
receipt_id = role_dispatch.get("receiptId")
|
||||
if isinstance(receipt_id, str) and receipt_id not in receipt_ids:
|
||||
receipt_ids.append(receipt_id)
|
||||
if not receipt_ids:
|
||||
return []
|
||||
|
||||
receipts = data.get("workerReceipts")
|
||||
if not isinstance(receipts, list):
|
||||
raise SelectionError("选中任务引用了 receipt,但 workerReceipts 不是列表")
|
||||
by_id = {
|
||||
receipt.get("id"): receipt
|
||||
for receipt in receipts
|
||||
if isinstance(receipt, dict) and isinstance(receipt.get("id"), str)
|
||||
}
|
||||
missing = [receipt_id for receipt_id in receipt_ids if receipt_id not in by_id]
|
||||
if missing:
|
||||
raise SelectionError("选中任务引用了未知 receipt: " + ", ".join(missing))
|
||||
return [by_id[receipt_id] for receipt_id in receipt_ids]
|
||||
|
||||
|
||||
def select_delivery_runs(
|
||||
data: dict[str, Any], selected: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
selected_ids = {
|
||||
task.get("id")
|
||||
for task in selected
|
||||
if isinstance(task.get("id"), str)
|
||||
}
|
||||
runs = data.get("deliveryRuns")
|
||||
if not selected_ids or not isinstance(runs, list):
|
||||
return []
|
||||
return [
|
||||
run
|
||||
for run in runs
|
||||
if isinstance(run, dict)
|
||||
and isinstance(run.get("taskIds"), list)
|
||||
and any(task_id in selected_ids for task_id in run["taskIds"])
|
||||
]
|
||||
|
||||
|
||||
def build_payload(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
mode: str,
|
||||
criteria: list[str],
|
||||
selected: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
receipts = select_referenced_receipts(data, selected)
|
||||
delivery_runs = select_delivery_runs(data, selected)
|
||||
tasks = data.get("tasks")
|
||||
metadata = {
|
||||
field: data[field]
|
||||
for field in BOARD_METADATA_FIELDS
|
||||
if field in data
|
||||
}
|
||||
selected_ids = [
|
||||
task["id"]
|
||||
for task in selected
|
||||
if isinstance(task.get("id"), str)
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"board": metadata,
|
||||
"project": data.get("project"),
|
||||
"summary": data.get("summary"),
|
||||
"selection": {
|
||||
"mode": mode,
|
||||
"criteria": criteria,
|
||||
"count": len(selected),
|
||||
"totalTasks": len(tasks) if isinstance(tasks, list) else 0,
|
||||
"selectedTaskIds": selected_ids,
|
||||
"referencedReceiptIds": [receipt.get("id") for receipt in receipts],
|
||||
"deliveryRunIds": [run.get("id") for run in delivery_runs],
|
||||
},
|
||||
"tasks": selected,
|
||||
"workerReceipts": receipts,
|
||||
"deliveryRuns": delivery_runs,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="选择 ACK 任务板的有预算 Coordinator 上下文"
|
||||
)
|
||||
parser.add_argument(
|
||||
"tasks",
|
||||
nargs="?",
|
||||
default="docs/ack/tasks.yaml",
|
||||
help="任务板路径",
|
||||
)
|
||||
parser.add_argument("--task-id", action="append", default=[])
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="append",
|
||||
default=[],
|
||||
choices=sorted(STATUS_ENUM),
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
tasks_path = Path(args.tasks)
|
||||
if not tasks_path.is_file():
|
||||
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
|
||||
return 2
|
||||
|
||||
data = load_document(tasks_path)
|
||||
errors = validate_builtin(data)
|
||||
if errors:
|
||||
sys.stderr.write(f"任务板无效,拒绝选择,共 {len(errors)} 项:\n")
|
||||
for error in errors:
|
||||
sys.stderr.write(f" - {error}\n")
|
||||
return 1
|
||||
|
||||
try:
|
||||
mode, criteria, selected = select_tasks(
|
||||
data,
|
||||
task_ids=args.task_id,
|
||||
statuses=args.status,
|
||||
limit=args.limit,
|
||||
)
|
||||
payload = build_payload(
|
||||
data,
|
||||
mode=mode,
|
||||
criteria=criteria,
|
||||
selected=selected,
|
||||
)
|
||||
except SelectionError as exc:
|
||||
sys.stderr.write(f"任务选择失败: {exc}\n")
|
||||
return 1
|
||||
|
||||
if args.compact:
|
||||
output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
else:
|
||||
output = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if len(output.encode("utf-8")) > MAX_OUTPUT_BYTES:
|
||||
sys.stderr.write(
|
||||
f"任务选择输出超过 {MAX_OUTPUT_BYTES} bytes;请进一步缩小任务范围\n"
|
||||
)
|
||||
return 1
|
||||
sys.stdout.write(output + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -98,7 +98,7 @@ DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
|
||||
CHANNELS = {"preview", "staging", "stable"}
|
||||
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
|
||||
CLASSIFICATIONS = {"development", "staging", "production"}
|
||||
STOP_POINTS = {"verified", "review_ready", "released"}
|
||||
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||
ACTIONS = {
|
||||
"verify",
|
||||
"pull-request",
|
||||
@@ -506,6 +506,7 @@ def _validate_profiles(
|
||||
built_artifacts: set[str] = set()
|
||||
published_artifacts: set[str] = set()
|
||||
deployed_environments: set[str] = set()
|
||||
checked_environments: set[str] = set()
|
||||
approvals: set[str] = set()
|
||||
has_pull_request = False
|
||||
has_mark_ready = False
|
||||
@@ -610,6 +611,8 @@ def _validate_profiles(
|
||||
errors.append(
|
||||
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
|
||||
)
|
||||
else:
|
||||
checked_environments.add(environment_id)
|
||||
if action == "approval":
|
||||
gate = step.get("gate")
|
||||
if gate not in {"release", "production"}:
|
||||
@@ -635,10 +638,21 @@ def _validate_profiles(
|
||||
)
|
||||
if stop_at == "released" and not ({"release", "production"} & approvals):
|
||||
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
|
||||
if stop_at == "validation_ready":
|
||||
if not deployed_environments:
|
||||
errors.append(f"{where}: validation_ready 必须至少部署一个环境")
|
||||
missing_health = deployed_environments - checked_environments
|
||||
if missing_health:
|
||||
errors.append(
|
||||
f"{where}: validation_ready 的部署环境必须全部完成 health-check: "
|
||||
f"{sorted(missing_health)}"
|
||||
)
|
||||
|
||||
if profile_id == default_profile:
|
||||
if stop_at != "review_ready":
|
||||
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
|
||||
if stop_at not in {"validation_ready", "review_ready"}:
|
||||
errors.append(
|
||||
f"{where}: defaultProfile 必须停在 validation_ready 或 review_ready"
|
||||
)
|
||||
used_destinations = {
|
||||
step.get("destination")
|
||||
for step in steps
|
||||
|
||||
@@ -240,20 +240,24 @@ def _validate_registry_target(
|
||||
def _tasks_project_root(
|
||||
tasks_data: dict[str, Any], tasks_path: Path | None
|
||||
) -> Path | None:
|
||||
inferred = infer_project_root(tasks_path) if tasks_path else None
|
||||
if inferred is not None and inferred.is_dir():
|
||||
return inferred
|
||||
|
||||
# Legacy task boards may still declare repoPath. It is only a fallback for
|
||||
# non-standard layouts; docs/ack location is authoritative when available.
|
||||
project = tasks_data.get("project")
|
||||
repo_path = project.get("repoPath") if isinstance(project, dict) else None
|
||||
if _nonempty(repo_path):
|
||||
candidate = Path(repo_path).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
inferred = infer_project_root(tasks_path) if tasks_path else None
|
||||
if inferred is None:
|
||||
return None
|
||||
candidate = inferred / candidate
|
||||
if not candidate.is_dir():
|
||||
return None
|
||||
return candidate.resolve(strict=True)
|
||||
inferred = infer_project_root(tasks_path) if tasks_path else None
|
||||
return inferred if inferred is not None and inferred.is_dir() else None
|
||||
return None
|
||||
|
||||
|
||||
def _unknown_keys(value: dict[str, Any], allowed: set[str], where: str) -> list[str]:
|
||||
@@ -808,8 +812,8 @@ def _validate_knowledge_file_binding(
|
||||
binding_root = project_root or declared_root
|
||||
if binding_root is None or not binding_root.is_dir():
|
||||
errors.append(
|
||||
"[tasks] 无法确定现有项目根目录;请修正 project.repoPath "
|
||||
"或传入 --project-root"
|
||||
"[tasks] 无法从 docs/ack 布局确定现有项目根目录;"
|
||||
"请传入 --project-root"
|
||||
)
|
||||
return errors
|
||||
try:
|
||||
@@ -1067,7 +1071,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
help="可选项目根目录;默认从 tasks.project.repoPath 或 docs/ack 布局推断",
|
||||
help="可选项目根目录;默认从 tasks.yaml 的 docs/ack 布局推断",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from approval_payload import approval_payload_hash
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
@@ -107,12 +108,31 @@ DELIVERY_STATUSES = {
|
||||
"running",
|
||||
"blocked",
|
||||
"failed",
|
||||
"validation_ready",
|
||||
"review_ready",
|
||||
"released",
|
||||
"skipped",
|
||||
}
|
||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||
FEISHU_REQUIRED_FIELDS = {
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||
"attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_OPTIONAL_FIELDS = {"priority", "fixLogic"}
|
||||
FEISHU_CLARIFIED_FIELDS = {
|
||||
"title", "details", "problemStatement", "expectedOutcome", "acceptance",
|
||||
"intakeStatus", "ackTaskId", "attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||
FEISHU_SOURCE_FIELDS = {
|
||||
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
|
||||
"approvedPayloadHash",
|
||||
}
|
||||
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
DISPATCH_FIELDS = {
|
||||
"taskId",
|
||||
"dispatchId",
|
||||
@@ -120,7 +140,34 @@ DISPATCH_FIELDS = {
|
||||
"developer",
|
||||
"test",
|
||||
"rounds",
|
||||
"environmentIncidents",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_FIELDS = {
|
||||
"id",
|
||||
"attemptId",
|
||||
"role",
|
||||
"phase",
|
||||
"status",
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt",
|
||||
"resolvedAt",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_ROLES = {"coordinator", "developer", "test"}
|
||||
ENVIRONMENT_INCIDENT_PHASES = {
|
||||
"launch",
|
||||
"orchestration",
|
||||
"service",
|
||||
"test_data",
|
||||
"browser",
|
||||
"tooling",
|
||||
"permissions",
|
||||
"other",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_STATUSES = {"open", "resolved"}
|
||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||
"kind",
|
||||
"title",
|
||||
@@ -526,7 +573,7 @@ def validate_delivery_runs(
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
|
||||
):
|
||||
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
|
||||
if status in {"review_ready", "released"} and not _nonempty_string(digest):
|
||||
if status in {"validation_ready", "review_ready", "released"} and not _nonempty_string(digest):
|
||||
errors.append(
|
||||
f"{artifact_where}.digest: status={status!r} 时必须填写"
|
||||
)
|
||||
@@ -569,7 +616,7 @@ def validate_delivery_runs(
|
||||
not _nonempty_string(item) for item in evidence
|
||||
):
|
||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||
elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence:
|
||||
elif status in {"blocked", "failed", "validation_ready", "review_ready", "released", "skipped"} and not evidence:
|
||||
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
|
||||
if not _nonempty_string(run.get("updatedAt")):
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
@@ -589,6 +636,7 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||
|
||||
def validate_builtin(data: dict) -> list[str]:
|
||||
errors: list[str] = []
|
||||
project_intake_workflow = "read-only-v1"
|
||||
|
||||
def validate_string_fields(
|
||||
value: dict,
|
||||
@@ -654,6 +702,51 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
||||
"project",
|
||||
)
|
||||
if "bugIntake" in project:
|
||||
intake = project["bugIntake"]
|
||||
if not isinstance(intake, dict):
|
||||
errors.append("project.bugIntake 必须是对象")
|
||||
else:
|
||||
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
|
||||
if intake.get("provider") != "feishu-base":
|
||||
errors.append("project.bugIntake.provider 必须是 feishu-base")
|
||||
workflow = intake.get("workflow", "read-only-v1")
|
||||
if workflow in FEISHU_WORKFLOWS:
|
||||
project_intake_workflow = workflow
|
||||
if workflow not in FEISHU_WORKFLOWS:
|
||||
errors.append("project.bugIntake.workflow 非法")
|
||||
profile = intake.get("profile")
|
||||
if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None:
|
||||
errors.append("project.bugIntake.profile 非法")
|
||||
for key in ("baseToken", "tableId", "viewId"):
|
||||
value = intake.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
||||
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
||||
fields = intake.get("fields")
|
||||
expected_fields = (
|
||||
FEISHU_CLARIFIED_FIELDS
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else FEISHU_REQUIRED_FIELDS
|
||||
)
|
||||
allowed_fields = expected_fields | (
|
||||
set() if workflow == "clarified-writeback-v1" else FEISHU_OPTIONAL_FIELDS
|
||||
)
|
||||
if (
|
||||
not isinstance(fields, dict)
|
||||
or not expected_fields.issubset(fields)
|
||||
or not set(fields).issubset(allowed_fields)
|
||||
):
|
||||
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
|
||||
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
|
||||
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
|
||||
elif len(set(fields.values())) != len(fields):
|
||||
errors.append("project.bugIntake.fields 字段值不能重复")
|
||||
elif workflow == "reviewed-writeback-v1" and not {
|
||||
"fixLogic", "priority"
|
||||
}.issubset(fields):
|
||||
errors.append(
|
||||
"reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic 和 priority"
|
||||
)
|
||||
if (
|
||||
"knowledgeFile" in project
|
||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||
@@ -710,6 +803,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
return errors
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
seen_source_refs: set[str] = set()
|
||||
for i, task in enumerate(tasks):
|
||||
where = f"tasks[{i}]"
|
||||
if not isinstance(task, dict):
|
||||
@@ -744,6 +838,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
"assignee",
|
||||
"component",
|
||||
"description",
|
||||
"fixLogic",
|
||||
"expected",
|
||||
"actual",
|
||||
},
|
||||
@@ -751,11 +846,86 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
)
|
||||
validate_string_lists(
|
||||
task,
|
||||
{"specRefs", "testRefs", "stepsToReproduce"},
|
||||
{"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"},
|
||||
where,
|
||||
)
|
||||
validate_object_fields(task, {"evidence", "verification"}, where)
|
||||
|
||||
if "source" in task:
|
||||
source = task["source"]
|
||||
# `source` was historically an open extension point. Preserve
|
||||
# non-Feishu strings/objects and tighten only the namespaced shape.
|
||||
if isinstance(source, dict) and source.get("kind") == "feishu-base":
|
||||
reject_unknown_fields(source, FEISHU_SOURCE_FIELDS, f"{where}.source", errors)
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or FEISHU_SOURCE_REF_RE.fullmatch(ref) is None:
|
||||
errors.append(f"{where}.source.ref: 必须是不透明 feishu-base SHA-256 引用")
|
||||
else:
|
||||
if ref in seen_source_refs:
|
||||
errors.append(f"{where}.source.ref: 来源引用重复")
|
||||
seen_source_refs.add(ref)
|
||||
record_id = source.get("recordId")
|
||||
if not isinstance(record_id, str) or FEISHU_RECORD_ID_RE.fullmatch(record_id) is None:
|
||||
errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID")
|
||||
if not _nonempty_string(source.get("updatedAt")):
|
||||
errors.append(f"{where}.source.updatedAt: 必须是非空字符串")
|
||||
source_workflow = source.get("workflow", "read-only-v1")
|
||||
if source_workflow not in FEISHU_WORKFLOWS:
|
||||
errors.append(f"{where}.source.workflow: 非法")
|
||||
if (
|
||||
project_intake_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
and source_workflow != project_intake_workflow
|
||||
and status not in {"verified", "leftover"}
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.source.workflow: reviewed 项目的可执行飞书任务必须先迁移审核"
|
||||
)
|
||||
approved_revision = source.get("approvedRevision")
|
||||
stored_payload_hash = source.get("approvedPayloadHash")
|
||||
is_approved_workflow = source_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
if is_approved_workflow and approved_revision is None:
|
||||
errors.append(f"{where}.source.approvedRevision: writeback workflow 必填")
|
||||
elif approved_revision is not None and (
|
||||
not isinstance(approved_revision, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
|
||||
):
|
||||
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
|
||||
if is_approved_workflow:
|
||||
if (
|
||||
not isinstance(stored_payload_hash, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
|
||||
):
|
||||
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
|
||||
required_strings = (
|
||||
("title", "description", "actual", "expected")
|
||||
if source_workflow == "clarified-writeback-v1"
|
||||
else ("title", "priority", "actual", "expected", "fixLogic")
|
||||
)
|
||||
for field in required_strings:
|
||||
if not _nonempty_string(task.get(field)):
|
||||
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
|
||||
required_lists = (
|
||||
("acceptanceCriteria",)
|
||||
if source_workflow == "clarified-writeback-v1"
|
||||
else ("stepsToReproduce", "acceptanceCriteria")
|
||||
)
|
||||
for field in required_lists:
|
||||
items = task.get(field)
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or not items
|
||||
or any(not _nonempty_string(item) for item in items)
|
||||
):
|
||||
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串列表")
|
||||
if (
|
||||
isinstance(stored_payload_hash, str)
|
||||
and re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash)
|
||||
and stored_payload_hash != approval_payload_hash(task)
|
||||
):
|
||||
errors.append(f"{where}.source.approvedPayloadHash: 与任务审核字段不匹配")
|
||||
elif stored_payload_hash is not None:
|
||||
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
||||
|
||||
validate_knowledge_fields(task, where, status, errors)
|
||||
|
||||
if "dispatch" not in task:
|
||||
@@ -843,6 +1013,72 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复"
|
||||
)
|
||||
|
||||
incidents = dispatch.get("environmentIncidents", [])
|
||||
if not isinstance(incidents, list):
|
||||
errors.append(f"{where}.dispatch.environmentIncidents: 必须是列表")
|
||||
else:
|
||||
seen_incident_ids: set[str] = set()
|
||||
for incident_index, incident in enumerate(incidents):
|
||||
incident_where = (
|
||||
f"{where}.dispatch.environmentIncidents[{incident_index}]"
|
||||
)
|
||||
if not isinstance(incident, dict):
|
||||
errors.append(f"{incident_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
incident,
|
||||
ENVIRONMENT_INCIDENT_FIELDS,
|
||||
incident_where,
|
||||
errors,
|
||||
)
|
||||
incident_id = incident.get("id")
|
||||
expected_id = (
|
||||
f"{tid}-ENV-{incident_index + 1}"
|
||||
if isinstance(tid, str)
|
||||
else None
|
||||
)
|
||||
if not isinstance(incident_id, str) or incident_id != expected_id:
|
||||
errors.append(f"{incident_where}.id: 应为 {expected_id}")
|
||||
elif incident_id in seen_incident_ids:
|
||||
errors.append(f"{incident_where}.id: 不能重复 {incident_id}")
|
||||
else:
|
||||
seen_incident_ids.add(incident_id)
|
||||
if incident.get("role") not in ENVIRONMENT_INCIDENT_ROLES:
|
||||
errors.append(
|
||||
f"{incident_where}.role: 必须是 coordinator/developer/test"
|
||||
)
|
||||
if incident.get("phase") not in ENVIRONMENT_INCIDENT_PHASES:
|
||||
errors.append(f"{incident_where}.phase: 非法环境阶段")
|
||||
incident_status = incident.get("status")
|
||||
if incident_status not in ENVIRONMENT_INCIDENT_STATUSES:
|
||||
errors.append(f"{incident_where}.status: 必须是 open/resolved")
|
||||
for field in (
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt",
|
||||
):
|
||||
if not _nonempty_string(incident.get(field)):
|
||||
errors.append(f"{incident_where}.{field}: 必须是非空字符串")
|
||||
if "attemptId" in incident and not (
|
||||
incident["attemptId"] is None
|
||||
or _nonempty_string(incident["attemptId"])
|
||||
):
|
||||
errors.append(f"{incident_where}.attemptId: 必须是字符串或 null")
|
||||
if "resolvedAt" in incident and not (
|
||||
incident["resolvedAt"] is None
|
||||
or _nonempty_string(incident["resolvedAt"])
|
||||
):
|
||||
errors.append(f"{incident_where}.resolvedAt: 必须是字符串或 null")
|
||||
if incident_status == "resolved" and not _nonempty_string(
|
||||
incident.get("resolvedAt")
|
||||
):
|
||||
errors.append(
|
||||
f"{incident_where}: resolved 必须填写 resolvedAt"
|
||||
)
|
||||
|
||||
resolution = task.get("resolution")
|
||||
if "resolution" in task:
|
||||
if not isinstance(resolution, dict):
|
||||
|
||||
@@ -68,6 +68,7 @@ RECEIPT_FIELDS = frozenset(
|
||||
"receiptHash",
|
||||
}
|
||||
)
|
||||
RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"})
|
||||
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
||||
WORKTREE_FIELDS = frozenset(
|
||||
{
|
||||
@@ -222,7 +223,6 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
||||
errors.append(
|
||||
f"{where}.permissionMode: must be read-only/workspace-write"
|
||||
)
|
||||
|
||||
if cli == "codex":
|
||||
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
||||
errors.append(
|
||||
@@ -613,8 +613,20 @@ def validate_worker_receipt(
|
||||
|
||||
if not isinstance(receipt, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS, where)
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS | RECEIPT_CONTEXT_FIELDS, where)
|
||||
errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where))
|
||||
project_root = receipt.get("projectRoot")
|
||||
board_hash = receipt.get("boardHash")
|
||||
if (project_root is None) != (board_hash is None):
|
||||
errors.append(f"{where}: projectRoot and boardHash must be present together")
|
||||
if project_root is not None and (
|
||||
not isinstance(project_root, str) or not project_root.startswith("/")
|
||||
):
|
||||
errors.append(f"{where}.projectRoot: must be an absolute path")
|
||||
if board_hash is not None and (
|
||||
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
|
||||
):
|
||||
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
|
||||
|
||||
version = receipt.get("receiptVersion")
|
||||
if version != RECEIPT_VERSION or isinstance(version, bool):
|
||||
@@ -728,18 +740,20 @@ def validate_worker_receipt(
|
||||
and isinstance(requested, dict)
|
||||
):
|
||||
try:
|
||||
expected_fingerprint = canonical_sha256(
|
||||
{
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
)
|
||||
facts = {
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
if project_root is not None and board_hash is not None:
|
||||
facts["projectRoot"] = project_root
|
||||
facts["boardHash"] = board_hash
|
||||
expected_fingerprint = canonical_sha256(facts)
|
||||
except ValueError:
|
||||
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
||||
else:
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
"properties": {
|
||||
"stopAt": {
|
||||
"type": "string",
|
||||
"enum": ["verified", "review_ready", "released"]
|
||||
"enum": ["verified", "validation_ready", "review_ready", "released"]
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
|
||||
@@ -51,8 +51,8 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
||||
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
|
||||
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
|
||||
`--expected-launch-fingerprint` 启动 fresh worker。v0.10 的 `receiptHash` 只作
|
||||
审计 checksum,不授权自动复用旧终端。
|
||||
`--expected-launch-fingerprint` 启动 fresh worker。`receiptHash` 只作审计 checksum,
|
||||
不授权自动复用旧终端;只有同轮空闲、身份匹配且历史消息可信清理后才可复用。
|
||||
|
||||
## 路径权限(项目覆盖层,必须填实际路径)
|
||||
|
||||
@@ -108,7 +108,8 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
||||
等第二配置面。
|
||||
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
|
||||
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
|
||||
- v0.10 不自动复用持久化 receipt 指向的旧终端;每次自动派发都重新 plan/launch。
|
||||
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
||||
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
||||
通过消息回报。
|
||||
@@ -119,7 +120,7 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
|
||||
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
|
||||
- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有
|
||||
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
||||
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||
|
||||
@@ -25,9 +25,17 @@
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"repoPath": { "type": "string" },
|
||||
"repoPath": {
|
||||
"type": "string",
|
||||
"deprecated": true,
|
||||
"description": "Legacy informational field; ACK derives project files from --project-root/docs/ack"
|
||||
},
|
||||
"baseUrl": { "type": "string" },
|
||||
"devWorktree": { "type": "string" },
|
||||
"devWorktree": {
|
||||
"type": "string",
|
||||
"deprecated": true,
|
||||
"description": "Legacy informational field; pass the actual worker path with --worktree"
|
||||
},
|
||||
"overlayFile": {
|
||||
"type": "string",
|
||||
"description": "项目覆盖层文件路径,默认 docs/ack/project.md,可自定义"
|
||||
@@ -42,6 +50,7 @@
|
||||
"const": "docs/ack/delivery.yaml",
|
||||
"description": "可选项目交付契约的唯一权威路径"
|
||||
},
|
||||
"bugIntake": { "$ref": "#/definitions/feishuBugIntake" },
|
||||
"orchestration": {
|
||||
"$ref": "#/definitions/orchestration"
|
||||
}
|
||||
@@ -164,6 +173,123 @@
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"feishuBugIntake": {
|
||||
"type": "object",
|
||||
"required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"provider": { "const": "feishu-base" },
|
||||
"workflow": {
|
||||
"type": "string",
|
||||
"enum": ["read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"]
|
||||
},
|
||||
"profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
||||
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"fixLogic": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
,"details": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
,"problemStatement": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
,"expectedOutcome": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
,"intakeStatus": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
,"ackTaskId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"not": {
|
||||
"properties": { "workflow": { "const": "clarified-writeback-v1" } },
|
||||
"required": ["workflow"]
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"fields": { "required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
|
||||
"required": ["workflow"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"fields": { "required": ["fixLogic", "priority"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
,{
|
||||
"if": {
|
||||
"properties": { "workflow": { "const": "clarified-writeback-v1" } },
|
||||
"required": ["workflow"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"fields": { "required": ["title", "details", "problemStatement", "expectedOutcome", "acceptance", "intakeStatus", "ackTaskId", "attachments", "updatedAt"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"feishuTaskSource": {
|
||||
"type": "object",
|
||||
"required": ["kind", "ref", "recordId", "updatedAt"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "feishu-base" },
|
||||
"workflow": {
|
||||
"type": "string",
|
||||
"enum": ["read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^feishu-base:sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"recordId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$"
|
||||
},
|
||||
"updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"approvedRevision": { "$ref": "#/definitions/sha256" },
|
||||
"approvedPayloadHash": { "$ref": "#/definitions/sha256" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
|
||||
"required": ["workflow"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["approvedRevision", "approvedPayloadHash"]
|
||||
}
|
||||
}
|
||||
,{
|
||||
"if": {
|
||||
"properties": { "workflow": { "const": "clarified-writeback-v1" } },
|
||||
"required": ["workflow"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["approvedRevision", "approvedPayloadHash"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,63}$"
|
||||
@@ -526,6 +652,13 @@
|
||||
"launchFingerprint": {
|
||||
"$ref": "#/definitions/sha256"
|
||||
},
|
||||
"projectRoot": {
|
||||
"type": "string",
|
||||
"pattern": "^/"
|
||||
},
|
||||
"boardHash": {
|
||||
"$ref": "#/definitions/sha256"
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
@@ -642,6 +775,57 @@
|
||||
"evidence": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"environmentIncident": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"role",
|
||||
"phase",
|
||||
"status",
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*-ENV-[1-9][0-9]*$"
|
||||
},
|
||||
"attemptId": { "type": ["string", "null"] },
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["coordinator", "developer", "test"]
|
||||
},
|
||||
"phase": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"launch",
|
||||
"orchestration",
|
||||
"service",
|
||||
"test_data",
|
||||
"browser",
|
||||
"tooling",
|
||||
"permissions",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["open", "resolved"]
|
||||
},
|
||||
"summary": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"evidence": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"impact": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"recoveryAction": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"userAction": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"reportedAt": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"resolvedAt": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"knowledgeRef": {
|
||||
"type": "string",
|
||||
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
|
||||
@@ -818,6 +1002,7 @@
|
||||
"running",
|
||||
"blocked",
|
||||
"failed",
|
||||
"validation_ready",
|
||||
"review_ready",
|
||||
"released",
|
||||
"skipped"
|
||||
@@ -856,6 +1041,7 @@
|
||||
"running",
|
||||
"blocked",
|
||||
"failed",
|
||||
"validation_ready",
|
||||
"review_ready",
|
||||
"released"
|
||||
]
|
||||
@@ -877,6 +1063,7 @@
|
||||
"enum": [
|
||||
"blocked",
|
||||
"failed",
|
||||
"validation_ready",
|
||||
"review_ready",
|
||||
"released",
|
||||
"skipped"
|
||||
@@ -940,7 +1127,23 @@
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/knowledgeCheck" }
|
||||
},
|
||||
"source": {
|
||||
"if": {
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"properties": { "kind": { "const": "feishu-base" } }
|
||||
},
|
||||
"then": {
|
||||
"$ref": "#/definitions/feishuTaskSource"
|
||||
}
|
||||
},
|
||||
"description": { "type": "string" },
|
||||
"fixLogic": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||
"acceptanceCriteria": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||
},
|
||||
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
||||
"expected": { "type": "string" },
|
||||
"actual": { "type": "string" },
|
||||
@@ -958,6 +1161,10 @@
|
||||
"rounds": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/round" }
|
||||
},
|
||||
"environmentIncidents": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/environmentIncident" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,12 +5,28 @@ source: "Coordinator (PM) Agent"
|
||||
ackVersion: "<接入时的 ack skill 版本>"
|
||||
project:
|
||||
name: "<project_name>"
|
||||
repoPath: "<repo_path>"
|
||||
baseUrl: "<base_url>"
|
||||
devWorktree: "<dev_worktree>"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
deliveryFile: "docs/ack/delivery.yaml"
|
||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||
# bugIntake:
|
||||
# provider: "feishu-base"
|
||||
# workflow: "clarified-writeback-v1"
|
||||
# profile: "project-feishu"
|
||||
# baseToken: "<base_token>"
|
||||
# tableId: "<table_id>"
|
||||
# viewId: "<ack_ready_view_id>"
|
||||
# fields:
|
||||
# title: "<field_id>"
|
||||
# details: "<field_id>"
|
||||
# problemStatement: "<field_id>"
|
||||
# expectedOutcome: "<field_id>"
|
||||
# acceptance: "<field_id>"
|
||||
# intakeStatus: "<field_id>"
|
||||
# ackTaskId: "<field_id>"
|
||||
# attachments: "<field_id>"
|
||||
# updatedAt: "<field_id>"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
@@ -95,9 +111,27 @@ tasks:
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||
# source:
|
||||
# kind: "feishu-base"
|
||||
# workflow: "reviewed-writeback-v1"
|
||||
# ref: "feishu-base:sha256:<64-lowercase-hex>"
|
||||
# recordId: "<record-id>"
|
||||
# updatedAt: "<source-updated-at>"
|
||||
# approvedRevision: "sha256:<approved-draft-digest>"
|
||||
# approvedPayloadHash: "sha256:<canonical-task-payload-digest>"
|
||||
|
||||
description: >
|
||||
<What is wrong, in user-visible terms.>
|
||||
|
||||
# 飞书审核通过后写入批准版本;审核前不创建这条 ACK 任务。
|
||||
fixLogic: >
|
||||
<Approved repair logic, affected behavior, invariants, and regression risks.>
|
||||
|
||||
acceptanceCriteria:
|
||||
- "<observable acceptance signal 1>"
|
||||
- "<observable acceptance signal 2>"
|
||||
|
||||
stepsToReproduce:
|
||||
- "<step 1>"
|
||||
- "<step 2>"
|
||||
@@ -136,6 +170,8 @@ tasks:
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds: []
|
||||
# 环境失败不占产品复验轮次;按 BUG-001-ENV-1... 连续编号,并写清 userAction。
|
||||
environmentIncidents: []
|
||||
|
||||
resolution:
|
||||
fixedBy: null
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# draw-prototype-flow
|
||||
|
||||
把产品想法或需求文档画成一份可编辑的 Draw.io 低保真原型,并同步表达用户动线、页面状态、角色权限和关键规则。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 想从零梳理 Web、移动端或后台系统的页面原型与用户流程。
|
||||
- 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。
|
||||
- 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。
|
||||
|
||||
可以指定「快速 / 标准 / 完整」档位;不指定时默认按产品评审所需的标准档交付。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
请用 draw-prototype-flow 为一个企业访客预约系统画低保真原型和用户动线。
|
||||
覆盖访客申请、员工审批、前台核验,以及驳回、过期和无权限状态,输出 editable.drawio。
|
||||
```
|
||||
|
||||
```text
|
||||
检查 current.drawio,把所有主按钮的去向、审批状态和错误恢复路径补完整,并保持现有视觉风格。
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
1. 整理角色、页面、动作、状态和规则。
|
||||
2. 绘制可编辑页面、关键状态和带标签的用户动线。
|
||||
3. 补充页面索引、权限矩阵或字段规则等必要规格。
|
||||
4. 校验 Draw.io 结构与跨图引用,并说明假设和未决项。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: draw-prototype-flow
|
||||
description: >-
|
||||
将产品想法、需求文档或现有系统整理成可编辑的低保真 Draw.io 原型、用户动线和产品规则说明板。
|
||||
当用户提出画原型图、线框图、用户流程、用户动线、页面跳转、交互状态、wireframe、user flow、
|
||||
UX flow、产品方案可视化,或要求检查和完善现有 .drawio 产品原型时使用。适合同时表达页面、弹窗、
|
||||
异常态、角色权限、状态流转、字段规则与操作反馈;不用于像素级高保真 UI、纯技术架构图或单张插画。
|
||||
---
|
||||
|
||||
# 绘制低保真原型与用户动线
|
||||
|
||||
把原型、动线和关键规则放进同一套可追踪的产品模型,交付原生可编辑的 `.drawio` 文件。
|
||||
|
||||
## 使用资源
|
||||
|
||||
- 快速单页原型复制 `assets/prototype-flow-template.drawio`,保留其画板尺寸、语义色和基础组件,再替换占位内容。
|
||||
- 多页面原型复制 `assets/scaffold-spec.example.json`,填写页面清单和 `transitions`,再运行 `scripts/scaffold_drawio.py` 生成可编辑骨架与总览连线,禁止手工从空 XML 重复搭建外壳。
|
||||
- 开始绘制前读取 `references/design-patterns.md`;遇到复杂业务、多个角色或超过 8 个页面时,再读取 `references/product-model.md`。
|
||||
- 完成后运行 `scripts/validate_drawio.py`。该脚本可检查常见的压缩或未压缩 Draw.io 文件;Agent 输出时仍设置 `compressed="false"`,便于审阅、差异比较和后续修改。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 确认交付边界
|
||||
|
||||
从用户材料中提取目标用户、核心任务、终端形态、必须覆盖的流程和输出路径。信息不全但不影响主流程时,采用合理假设并在交付说明中列出;只有会改变产品范围或关键流程时才询问。
|
||||
|
||||
默认交付:
|
||||
|
||||
- 原生可编辑 `.drawio`;
|
||||
- 一张端到端动线总览;
|
||||
- 核心任务的页面与关键状态;
|
||||
- 页面索引、状态流转和角色权限等必要规格;
|
||||
- 可选的 PNG/SVG 预览,仅作为查看副本,不能代替源文件。
|
||||
|
||||
根据请求选择交付档位,避免把概念验证画成大而全的规格库:
|
||||
|
||||
- **快速**:1 条核心动线、3~8 个画板、最高风险的 1~2 个异常;适合早期讨论。
|
||||
- **标准(默认)**:核心任务、8~20 个画板、动线总览、关键状态和必要的权限/字段规则;适合产品评审。
|
||||
- **完整**:多角色、多业务域、系统异常、状态机、权限矩阵和详细规格;适合开发与测试对齐。
|
||||
|
||||
用户没有指定时,根据材料成熟度选择,先说明档位和不覆盖的内容。
|
||||
|
||||
### 2. 先建模,再画图
|
||||
|
||||
建立稳定 ID,避免用会随排序变化的显示序号充当引用:
|
||||
|
||||
- 角色:`R-APPLICANT`、`R-APPROVER`;
|
||||
- 页面:`P-LOGIN`、`P-ORDER-LIST`;
|
||||
- 状态:`S-DRAFT`、`S-PENDING`;
|
||||
- 动作:`A-SUBMIT`、`A-REJECT`。
|
||||
|
||||
至少整理以下关系:
|
||||
|
||||
1. 角色 → 目标 → 入口;
|
||||
2. 页面/弹窗 → 可见信息 → 可执行动作;
|
||||
3. 动作 → 前置条件 → 成功去向 → 失败反馈;
|
||||
4. 业务对象 → 状态 → 可编辑性 → 角色权限;
|
||||
5. 字段 → 必填/值域/联动 → 校验反馈。
|
||||
|
||||
复杂任务使用 `references/product-model.md` 中的清单模板。先消除断头页面、无去向按钮和无入口状态,再开始排版。
|
||||
|
||||
### 3. 选择画布结构
|
||||
|
||||
- 不超过 12 个交互画板:可放在一个 Draw.io 页面,按「主流程 → 异常态 → 规格」排列。
|
||||
- 超过 12 个交互画板:默认拆成多个 Draw.io 页面,例如 `00-总览`、`10-申请人`、`20-审批人`、`30-异常状态`、`40-规格`。总览页保留所有跨域跳转。
|
||||
- 用户明确需要一张巨幅说明板时才使用单页矩阵;每行最多 5 个 1600×900 画板,横向间距 120,纵向间距 260,先放原型、再放流程和规格。
|
||||
|
||||
这里的“交互画板”只统计用户可进入的页面、弹窗和异常状态;动线总览、页面索引、字段字典和权限矩阵不参与 12 个阈值计算。生成多页骨架:
|
||||
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/scaffold_drawio.py scaffold-spec.json output.drawio
|
||||
```
|
||||
|
||||
每个画板标题使用「显示序号 · 页面名 · 状态」;底部元信息写角色、入口、核心功能和下一步,但跨页引用使用稳定 ID,例如 `下一步:P-DETAIL`,不要手写易漂移的序号。
|
||||
|
||||
### 4. 绘制页面与状态
|
||||
|
||||
遵循 `references/design-patterns.md` 的视觉语法,并保持低保真:
|
||||
|
||||
- 使用一致的应用外壳、导航、标题层级、表单、表格、按钮和反馈组件;
|
||||
- 主操作、成功、警告、危险和辅助跳转使用固定语义色;
|
||||
- 按钮、字段、弹窗和表格单元格保持独立图元,禁止把整个页面栅格化为图片;
|
||||
- 为核心任务画成功路径,也覆盖与业务相关的加载、空数据、校验失败、权限不足、并发冲突和不可逆确认;
|
||||
- 弹窗作为触发页面的明确状态,必须同时画取消/返回路径和确认后的结果;
|
||||
- 内容优先使用真实语义的示例数据,避免大段 Lorem Ipsum。
|
||||
|
||||
### 5. 绘制动线
|
||||
|
||||
在总览中按角色或阶段使用泳道。所有跳转必须使用 Draw.io edge,并设置真实的 `source`、`target`:
|
||||
|
||||
- 节点写页面稳定 ID 与名称;
|
||||
- 连线标签写用户动作或系统结果,如「提交审批」「保存失败」「自动完结」;
|
||||
- 判断节点写条件,分支在线上写具体结果;
|
||||
- 成功用绿、警告/可恢复异常用橙、破坏性操作用红、普通导航用蓝;
|
||||
- 连线优先正交,绕开画板与文字,不用无来源或无目标的装饰箭头;
|
||||
- 每个入口可达,每个非终态有去向,每个终态有清晰结果。
|
||||
|
||||
### 6. 补齐产品规格
|
||||
|
||||
原型之后至少提供页面与动作索引。按复杂度增加:
|
||||
|
||||
- 角色 × 页面 × 操作权限矩阵;
|
||||
- 状态流转、触发动作、操作者和结果;
|
||||
- 字段字典、值域、必填与联动规则;
|
||||
- 异常反馈与恢复动作;
|
||||
- 报表、导出或批处理口径。
|
||||
|
||||
不要为凑数量复制页面。规格应解释原型无法准确表达的规则,并使用与原型相同的稳定 ID。
|
||||
|
||||
### 7. 一致性检查
|
||||
|
||||
逐项核对:
|
||||
|
||||
1. 每个核心任务都能从入口走到明确结果;
|
||||
2. 每个可点击主操作都在动线或规格中有去向;
|
||||
3. 每条连线的源按钮、目标页面和标签语义一致;
|
||||
4. 页面状态、角色权限、字段规则和按钮可编辑性不冲突;
|
||||
5. 确认弹窗、失败反馈和恢复路径成对出现;
|
||||
6. 所有引用使用稳定 ID,显示序号没有被当作唯一标识;
|
||||
7. 文字没有明显截断,图元不重叠,连线不穿过主要内容。
|
||||
|
||||
运行结构校验:
|
||||
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/validate_drawio.py <output.drawio> --min-frames 1
|
||||
```
|
||||
|
||||
存在两个以上交互画板时追加 `--min-edges 1`,确保至少有一条真实动线;正式交付按核心转移清单设置更高的最小连线数。
|
||||
|
||||
多页面或正式交付追加 `--strict`;如果环境有 Draw.io 导出工具,再导出 PNG/SVG 并目视检查全图和 2~3 个关键画板。不要把包含私有需求的文件上传到公共在线转换服务。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- `.drawio` 能被 XML 解析并在 diagrams.net/Draw.io 打开;
|
||||
- 图元和文字可单独编辑,关键跳转是有源和目标的连接线;
|
||||
- 核心流程、关键分支、角色权限和业务状态相互一致;
|
||||
- 校验无 error;正式交付在 `--strict` 下也无 warning,或逐条说明保留原因;
|
||||
- 向用户列出产物路径、采用的假设、覆盖范围以及仍待产品决策的问题。
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" agent="draw-prototype-flow" version="24.7.17" type="device" compressed="false">
|
||||
<diagram id="prototype-template" name="00-原型与动线">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="label-p01" value="01 · P-LIST · 任务列表" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="0" y="15" width="1000" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="0" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-nav" value="产品名称
工作台
我的任务
数据概览
系统设置" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#061B3A;strokeColor=#061B3A;fontColor=#C8D6EA;fontSize=16;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=32;spacingLeft=32;" vertex="1" parent="1"><mxGeometry x="0" y="70" width="260" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-title" value="我的任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="120" width="500" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-desc" value="查看进度、继续处理并创建新任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=14;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="170" width="600" height="30" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-filter" value="状态:全部 关键词:请输入名称 查询" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;arcSize=8;" vertex="1" parent="1"><mxGeometry x="310" y="225" width="1240" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-new" value="+ 创建任务" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="1330" y="130" width="220" height="48" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-table-head" value="编号 名称 状态 更新时间 操作" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="335" width="1240" height="54" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-table-row1" value="T-001 示例任务 A 待处理 2026-08-04 10:30 查看 编辑" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="389" width="1240" height="62" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-table-row2" value="T-002 示例任务 B 已完成 2026-08-03 16:20 查看" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F7F9FC;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="451" width="1240" height="62" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-meta" value="角色:R-USER|入口:登录后首页|功能:筛选、查看、创建|下一步:P-EDIT" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="915" width="1240" height="30" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p02" value="02 · P-EDIT · 创建任务 · 编辑中" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1720" y="15" width="1000" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="1720" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-nav" value="产品名称
工作台
我的任务
数据概览
系统设置" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#061B3A;strokeColor=#061B3A;fontColor=#C8D6EA;fontSize=16;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=32;spacingLeft=32;" vertex="1" parent="1"><mxGeometry x="1720" y="70" width="260" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-title" value="创建任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="2030" y="120" width="500" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-form" value="" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2030" y="210" width="1240" height="570" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-field1" value="任务名称 *
请输入任务名称" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2080" y="270" width="540" height="74" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-field2" value="任务类型 *
请选择类型 ▾" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2670" y="270" width="540" height="74" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-field3" value="任务说明
请输入必要的背景、范围和完成标准" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=14;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2080" y="390" width="1130" height="180" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-cancel" value="取消" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="2720" y="700" width="200" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-submit" value="提交" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="2970" y="700" width="240" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-meta" value="角色:R-USER|入口:P-LIST / A-CREATE|状态:S-EDITING|下一步:P-CONFIRM" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="2030" y="915" width="1240" height="30" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p03" value="03 · P-CONFIRM · 提交确认" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="3440" y="15" width="1000" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="3440" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-context" value="创建任务页面(保留用户已填写内容)" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#718198;fontSize=18;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="3540" y="170" width="1400" height="650" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-overlay" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#10284B;strokeColor=none;opacity=25;" vertex="1" parent="1"><mxGeometry x="3440" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-modal" value="" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;arcSize=10;" vertex="1" parent="1"><mxGeometry x="3750" y="300" width="980" height="420" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-title" value="确认提交任务?" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=26;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="3820" y="355" width="600" height="45" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-desc" value="提交后将进入处理流程。你仍可在任务详情中查看进度。" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#40516A;fontSize=16;fontFamily=Microsoft YaHei;align=left;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry x="3820" y="425" width="820" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-back" value="返回修改" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="4080" y="590" width="220" height="52" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-confirm" value="确认提交" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#18864B;strokeColor=#18864B;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="4340" y="590" width="260" height="52" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-meta" value="角色:R-USER|触发:P-EDIT / A-SUBMIT|取消:返回编辑|确认:S-SUBMITTED" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="3750" y="915" width="1240" height="30" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p04" value="04 · FLOW-OVERVIEW · 端到端用户动线" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="0" y="1175" width="1200" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p04-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="0" y="1230" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p04-lane-user" value="R-USER 用户" style="swimlane;html=0;rounded=0;startSize=70;horizontal=0;fillColor=#EEF4FF;strokeColor=#9CBDF0;fontColor=#1768E8;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="70" y="1310" width="1460" height="330" as="geometry"/></mxCell>
|
||||
<mxCell id="p04-lane-system" value="R-SYSTEM 系统" style="swimlane;html=0;rounded=0;startSize=70;horizontal=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="70" y="1670" width="1460" height="330" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-start" value="开始" style="ellipse;whiteSpace=wrap;html=0;fillColor=#EEF4FF;strokeColor=#1768E8;fontColor=#1768E8;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="180" y="1430" width="120" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-list" value="P-LIST
任务列表" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="410" y="1415" width="220" height="100" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-edit" value="P-EDIT
创建任务" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="760" y="1415" width="220" height="100" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-confirm" value="P-CONFIRM
提交确认" style="rhombus;whiteSpace=wrap;html=0;fillColor=#FFF8E8;strokeColor=#D97706;fontColor=#604A16;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1110" y="1395" width="220" height="140" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-process" value="S-SUBMITTED
创建处理记录" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#EAF8F0;strokeColor=#89D3A8;fontColor=#18864B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="760" y="1785" width="260" height="100" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-end" value="完成" style="ellipse;whiteSpace=wrap;html=0;fillColor=#EAF8F0;strokeColor=#18864B;fontColor=#18864B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1190" y="1800" width="120" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e1" value="进入" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="flow-start" target="flow-list"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e2" value="创建" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="flow-list" target="flow-edit"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e3" value="提交" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#D97706;fontColor=#40516A;" edge="1" parent="1" source="flow-edit" target="flow-confirm"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e4" value="确认" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="flow-confirm" target="flow-process"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e5" value="处理成功" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="flow-process" target="flow-end"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p05" value="05 · SPEC-INDEX · 页面、状态与操作索引" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1720" y="1175" width="1200" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="1720" y="1230" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-title" value="页面与动作索引" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="1810" y="1310" width="700" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-head" value="稳定 ID 页面/状态 角色 主操作 下一步" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1400" width="1420" height="56" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-row1" value="P-LIST 任务列表 R-USER A-CREATE P-EDIT" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1456" width="1420" height="64" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-row2" value="P-EDIT 创建任务 R-USER A-SUBMIT P-CONFIRM" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F7F9FC;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1520" width="1420" height="64" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-row3" value="P-CONFIRM 提交确认 R-USER A-CONFIRM S-SUBMITTED" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1584" width="1420" height="64" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-note" value="规则:所有原型、动线、状态机与权限矩阵复用稳定 ID;显示序号只代表当前阅读顺序。" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFF8E8;strokeColor=#E6BE65;fontColor=#604A16;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;arcSize=8;" vertex="1" parent="1"><mxGeometry x="1810" y="1730" width="1420" height="80" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="cross-e1" value="创建任务" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="p01-new" target="p02-title"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="cross-e2" value="提交确认" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="p02-submit" target="p03-title"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"title": "产品名称",
|
||||
"columns": 3,
|
||||
"transitions": [
|
||||
{
|
||||
"from": "P-LIST",
|
||||
"to": "P-EDIT",
|
||||
"label": "创建任务",
|
||||
"kind": "primary"
|
||||
},
|
||||
{
|
||||
"from": "P-EDIT",
|
||||
"to": "P-LIST",
|
||||
"label": "提交成功",
|
||||
"kind": "success"
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"name": "00-总览",
|
||||
"kind": "flow",
|
||||
"frames": [
|
||||
{
|
||||
"id": "FLOW-OVERVIEW",
|
||||
"title": "端到端用户动线",
|
||||
"role": "ALL",
|
||||
"state": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10-核心页面",
|
||||
"kind": "screen",
|
||||
"frames": [
|
||||
{
|
||||
"id": "P-LIST",
|
||||
"title": "任务列表",
|
||||
"role": "R-USER",
|
||||
"state": "S-DEFAULT"
|
||||
},
|
||||
{
|
||||
"id": "P-EDIT",
|
||||
"title": "创建任务",
|
||||
"role": "R-USER",
|
||||
"state": "S-EDITING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40-规格",
|
||||
"kind": "spec",
|
||||
"frames": [
|
||||
{
|
||||
"id": "SPEC-PAGE-INDEX",
|
||||
"title": "页面与动作索引",
|
||||
"role": "ALL",
|
||||
"state": ""
|
||||
},
|
||||
{
|
||||
"id": "SPEC-PERMISSION",
|
||||
"title": "角色权限矩阵",
|
||||
"role": "ALL",
|
||||
"state": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# 低保真产品说明板设计模式
|
||||
|
||||
## 1. 三层交付结构
|
||||
|
||||
一份可落地的产品原型同时包含三层:
|
||||
|
||||
1. **页面层**:用户看见什么、能做什么,包括页面、抽屉、弹窗和关键状态。
|
||||
2. **动线层**:谁在什么条件下执行什么动作,从哪里到哪里,失败后如何恢复。
|
||||
3. **规则层**:状态、权限、字段值域、联动、统计口径以及操作反馈。
|
||||
|
||||
只画页面会遗漏行为,只画流程会缺少界面上下文,只画规则又难以评审。三层必须复用同一套稳定 ID。
|
||||
|
||||
## 2. 画板与信息层级
|
||||
|
||||
默认桌面端画板为 1600×900;移动端改为统一设备宽高,但同一文件不要混用多个无说明尺寸。
|
||||
|
||||
每个画板从上到下包含:
|
||||
|
||||
- 外部编号标题:`03 · P-REQUEST-EDIT · 编辑申请 · 草稿`;
|
||||
- 应用外壳:产品名、当前角色、导航和环境信息;
|
||||
- 页面标题与一句任务说明;
|
||||
- 主要内容区;
|
||||
- 主次操作;
|
||||
- 底部元信息:角色、入口、关键规则和下一步稳定 ID。
|
||||
|
||||
画板之间保留足够留白,让跨画板连线走在间隙中。弹窗状态可单独复制一张触发页面并叠加 20%~30% 遮罩,避免脱离上下文的孤立弹窗。
|
||||
|
||||
## 3. 视觉语法
|
||||
|
||||
除非用户提供品牌规范,使用以下克制的基线:
|
||||
|
||||
| 语义 | 建议色 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 深色外壳 | `#061B3A` | 侧边栏、顶栏、产品框架 |
|
||||
| 主操作 | `#1768E8` | 主要按钮、普通导航连线 |
|
||||
| 成功 | `#18864B` | 成功反馈、通过、完成 |
|
||||
| 警告 | `#D97706` | 可恢复异常、未保存、待处理 |
|
||||
| 危险 | `#D92D20` | 删除、废弃、不可逆动作 |
|
||||
| 辅助跳转 | `#5B6ABF` | 预览、详情、跨域辅助流程 |
|
||||
| 主文字 | `#10284B` | 标题与正文重点 |
|
||||
| 次文字 | `#718198` | 说明、占位和元信息 |
|
||||
| 页面背景 | `#F4F7FB` | 内容画布 |
|
||||
| 边框 | `#D9E2EF` | 卡片、表格、输入框 |
|
||||
|
||||
低保真仍应有层级,但不要添加装饰性插画、复杂阴影、渐变或像素级视觉润色。圆角统一为 8~10,正文 12~14,区块标题 18,页面标题 28~30。中文优先使用环境常见无衬线字体并提供回退。
|
||||
|
||||
## 4. 组件表达
|
||||
|
||||
- **按钮**:独立图元,文字使用动词;危险动作不用模糊文案。
|
||||
- **输入框**:同时表达字段名、当前值/占位、必填和错误反馈,不能只画空矩形。
|
||||
- **表格**:表头、至少一条典型数据、空态与行操作;大表无需填满整页。
|
||||
- **状态标签**:颜色与文案双重编码,不能只靠颜色。
|
||||
- **反馈**:就近错误用于字段校验,全局消息用于操作结果,阻断性问题使用弹窗或整页状态。
|
||||
- **附件/媒体**:画出上传中、失败、预览、删除和权限差异中真正相关的状态。
|
||||
|
||||
## 5. 动线表达
|
||||
|
||||
总览使用角色或阶段泳道。页面节点使用稳定 ID;操作节点以动词开头;判断节点写可验证条件;开始和结束节点清楚区分。
|
||||
|
||||
连接线要求:
|
||||
|
||||
- 使用正交线并设置 `source`、`target`;
|
||||
- 线上的标签描述动作或结果,不重复节点名称;
|
||||
- 同一语义使用同一颜色,不给每条线随机配色;
|
||||
- 交叉多时拆分页面或增加局部子流程,不依赖长距离回折线;
|
||||
- 页面内按钮到目标页的连线与总览中的转移必须一致。
|
||||
|
||||
## 6. 页面与状态覆盖
|
||||
|
||||
按业务风险选择,不机械地给每页复制全部状态:
|
||||
|
||||
- 核心成功路径;
|
||||
- 表单校验与保存失败;
|
||||
- 加载、空数据和加载失败;
|
||||
- 无权限、账号异常或配置缺失;
|
||||
- 未保存离开;
|
||||
- 删除、撤回、废弃等不可逆确认;
|
||||
- 审批或库存等并发冲突;
|
||||
- 只读、已结束和审计视角。
|
||||
|
||||
## 7. 大型原型的默认优化
|
||||
|
||||
大型说明板容易出现两个问题:单页过大导致导航困难;显示序号变化导致页内引用失真。默认采用多页面结构与稳定 ID,显示序号只用于阅读顺序。
|
||||
|
||||
建议页签:
|
||||
|
||||
- `00-总览`:角色、核心任务、端到端动线、状态机;
|
||||
- `10-核心页面`:主流程页面;
|
||||
- `20-角色或子域`:按角色/业务域拆分;
|
||||
- `30-异常状态`:跨页面异常与恢复;
|
||||
- `40-规格`:索引、权限、字段与口径。
|
||||
|
||||
如果用户要求单页巨幅板,仍先在左上放导航索引,并限制每行画板数量。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 产品模型与覆盖检查
|
||||
|
||||
复杂原型先用以下表格建立轻量 SSOT。可以放在工作笔记中,也可以作为 Draw.io 的规格页;不要让原型、动线和规则各自维护一套名称。
|
||||
|
||||
## 页面清单
|
||||
|
||||
| 稳定 ID | 页面/状态 | 角色 | 入口 | 用户目标 | 主操作 | 成功去向 | 关键异常 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| P-LOGIN | 登录 | 访客 | 未登录 | 进入系统 | 登录 | P-HOME | S-AUTH-FAIL |
|
||||
|
||||
检查:每个页面都有入口;非终态至少有一个去向;弹窗写明触发页面。
|
||||
|
||||
## 转移清单
|
||||
|
||||
| 动作 ID | 来源 | 操作者 | 前置条件 | 动作 | 成功目标 | 失败目标/反馈 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| A-SUBMIT | P-EDIT | 申请人 | 校验通过 | 提交 | P-DETAIL / S-PENDING | P-EDIT / 保存失败消息 |
|
||||
|
||||
检查:每个主按钮有一条转移;每条连线对应真实操作;失败后能重试、返回或安全结束。
|
||||
|
||||
## 状态机
|
||||
|
||||
| 当前状态 | 可执行动作 | 角色 | 下一状态 | 字段可编辑性 | 记录/通知 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| S-DRAFT | A-SUBMIT | 申请人 | S-PENDING | 全部可编辑 | 创建审批任务 |
|
||||
|
||||
检查:状态名称唯一;每次变化有触发者;终态不会意外回到可编辑状态;并发变化有刷新或冲突处理。
|
||||
|
||||
## 角色权限矩阵
|
||||
|
||||
| 页面/动作 | 申请人 | 审批人 | 管理员 | 无权限表现 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| P-DETAIL / 查看 | 本人数据 | 分配给自己的数据 | 全部 | 403 或无入口 |
|
||||
| A-DELETE / 删除 | 仅草稿 | 无 | 按规则 | 隐藏按钮或阻断 |
|
||||
|
||||
检查:区分「看不见」「只读」「可操作」;数据范围和按钮权限同时表达;管理员操作注明审计要求。
|
||||
|
||||
## 字段规则
|
||||
|
||||
| 字段 ID | 页面 | 类型 | 必填 | 值域/格式 | 显示条件 | 联动 | 错误反馈 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| F-AMOUNT | P-EDIT | 金额 | 是 | > 0,2 位小数 | 始终 | 影响合计 | 请输入有效金额 |
|
||||
|
||||
检查:原型中的标签与字段表一致;依赖字段有清空/保留规则;日期、金额、附件和枚举给出边界。
|
||||
|
||||
## 异常与恢复
|
||||
|
||||
| 异常 ID | 触发点 | 用户看到什么 | 数据是否保留 | 恢复动作 | 记录 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| E-SAVE-TIMEOUT | A-SAVE | 保存超时消息 | 保留本地输入 | 重试 | 请求 ID |
|
||||
|
||||
检查:错误文案可行动;不可恢复问题给出返回路径;危险操作有二次确认;成功反馈不与实际状态矛盾。
|
||||
|
||||
## 端到端覆盖
|
||||
|
||||
每个核心任务至少验证一条成功路径和一条高风险分支:
|
||||
|
||||
```text
|
||||
角色 → 入口 → 页面 → 动作 → 状态变化 → 下一角色/系统处理 → 终态 → 可见反馈
|
||||
```
|
||||
|
||||
发布前抽查:
|
||||
|
||||
- 新用户第一次进入;
|
||||
- 正常创建并完成;
|
||||
- 中途保存和再次进入;
|
||||
- 校验失败或服务失败;
|
||||
- 无权限或配置缺失;
|
||||
- 不可逆动作;
|
||||
- 多角色交接;
|
||||
- 已结束对象的只读/审计视角。
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate an editable multi-page Draw.io prototype skeleton from JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRAME_W = 1600
|
||||
FRAME_H = 900
|
||||
GAP_X = 120
|
||||
GAP_Y = 260
|
||||
|
||||
STYLE = {
|
||||
"label": "text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;",
|
||||
"bg": "rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;",
|
||||
"nav": "rounded=0;whiteSpace=wrap;html=0;fillColor=#061B3A;strokeColor=#061B3A;fontColor=#C8D6EA;fontSize=16;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=32;spacingLeft=32;",
|
||||
"title": "text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;",
|
||||
"card": "rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#718198;fontSize=16;fontFamily=Microsoft YaHei;arcSize=8;",
|
||||
"primary": "rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;",
|
||||
"secondary": "rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;",
|
||||
"meta": "text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;",
|
||||
"table_head": "rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;",
|
||||
"table_row": "rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;",
|
||||
}
|
||||
|
||||
EDGE_COLORS = {
|
||||
"primary": "#1768E8",
|
||||
"success": "#18864B",
|
||||
"warning": "#D97706",
|
||||
"danger": "#D92D20",
|
||||
"secondary": "#5B6ABF",
|
||||
}
|
||||
|
||||
|
||||
def args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("spec", type=Path, help="JSON scaffold specification")
|
||||
parser.add_argument("output", type=Path, help="Target .drawio path")
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite an existing output file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def safe_id(value: str) -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9_-]+", "-", value).strip("-").lower()
|
||||
return value or "frame"
|
||||
|
||||
|
||||
def geometry(parent: ET.Element, x: int, y: int, width: int, height: int) -> None:
|
||||
ET.SubElement(parent, "mxGeometry", {
|
||||
"x": str(x), "y": str(y), "width": str(width), "height": str(height), "as": "geometry"
|
||||
})
|
||||
|
||||
|
||||
def cell(root: ET.Element, cell_id: str, value: str, style: str, x: int, y: int,
|
||||
width: int, height: int) -> ET.Element:
|
||||
node = ET.SubElement(root, "mxCell", {
|
||||
"id": cell_id, "value": value, "style": style, "vertex": "1", "parent": "1"
|
||||
})
|
||||
geometry(node, x, y, width, height)
|
||||
return node
|
||||
|
||||
|
||||
def edge(root: ET.Element, edge_id: str, value: str, source: str, target: str, kind: str) -> None:
|
||||
color = EDGE_COLORS.get(kind, EDGE_COLORS["primary"])
|
||||
node = ET.SubElement(root, "mxCell", {
|
||||
"id": edge_id,
|
||||
"value": value,
|
||||
"style": (
|
||||
"edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;"
|
||||
f"strokeWidth=2;strokeColor={color};fontColor=#40516A;"
|
||||
),
|
||||
"edge": "1",
|
||||
"parent": "1",
|
||||
"source": source,
|
||||
"target": target,
|
||||
})
|
||||
ET.SubElement(node, "mxGeometry", {"relative": "1", "as": "geometry"})
|
||||
|
||||
|
||||
def screen_frame(root: ET.Element, prefix: str, number: int, frame: dict, x: int, y: int,
|
||||
product: str) -> None:
|
||||
stable_id = frame["id"]
|
||||
title = frame["title"]
|
||||
state = frame.get("state", "")
|
||||
role = frame.get("role", "")
|
||||
suffix = f" · {state}" if state else ""
|
||||
cell(root, f"{prefix}-label", f"{number:02d} · {stable_id} · {title}{suffix}", STYLE["label"], x, y, 1300, 40)
|
||||
top = y + 55
|
||||
cell(root, f"{prefix}-bg", "", STYLE["bg"], x, top, FRAME_W, FRAME_H)
|
||||
cell(root, f"{prefix}-nav", f"{product}\n工作台\n核心任务\n数据概览\n系统设置", STYLE["nav"], x, top, 260, FRAME_H)
|
||||
cell(root, f"{prefix}-title", title, STYLE["title"], x + 310, top + 50, 700, 50)
|
||||
cell(root, f"{prefix}-card", "在此绘制页面内容、字段、表格和反馈状态", STYLE["card"], x + 310, top + 150, 1240, 560)
|
||||
cell(root, f"{prefix}-secondary", "次要操作", STYLE["secondary"], x + 1060, top + 745, 200, 50)
|
||||
cell(root, f"{prefix}-primary", "主要操作", STYLE["primary"], x + 1310, top + 745, 240, 50)
|
||||
cell(root, f"{prefix}-meta", f"角色:{role or '待定义'}|状态:{state or '待定义'}|稳定 ID:{stable_id}|下一步:待定义", STYLE["meta"], x + 310, top + 845, 1240, 30)
|
||||
|
||||
|
||||
def flow_frame(root: ET.Element, prefix: str, number: int, frame: dict, x: int, y: int) -> None:
|
||||
stable_id, title = frame["id"], frame["title"]
|
||||
cell(root, f"{prefix}-label", f"{number:02d} · {stable_id} · {title}", STYLE["label"], x, y, 1300, 40)
|
||||
top = y + 55
|
||||
cell(root, f"{prefix}-bg", "", STYLE["bg"], x, top, FRAME_W, FRAME_H)
|
||||
cell(root, f"{prefix}-title", title, STYLE["title"], x + 80, top + 55, 900, 50)
|
||||
cell(root, f"{prefix}-card", "按角色或阶段添加泳道;使用带 source/target 的正交连接线表达动作与结果", STYLE["card"], x + 80, top + 150, 1440, 620)
|
||||
cell(root, f"{prefix}-meta", f"稳定 ID:{stable_id}|覆盖:入口、判断、跨角色交接、成功与失败终态", STYLE["meta"], x + 80, top + 835, 1440, 30)
|
||||
|
||||
|
||||
def flow_graph(root: ET.Element, prefix: str, transitions: list[dict], frames: dict[str, dict],
|
||||
x: int, y: int) -> None:
|
||||
ordered_ids: list[str] = []
|
||||
for transition in transitions:
|
||||
for stable_id in (transition["from"], transition["to"]):
|
||||
if stable_id not in ordered_ids:
|
||||
ordered_ids.append(stable_id)
|
||||
|
||||
node_ids: dict[str, str] = {}
|
||||
for index, stable_id in enumerate(ordered_ids):
|
||||
col, row = index % 5, index // 5
|
||||
node_id = f"{prefix}-node-{safe_id(stable_id)}"
|
||||
node_ids[stable_id] = node_id
|
||||
frame = frames[stable_id]
|
||||
cell(
|
||||
root,
|
||||
node_id,
|
||||
f"{stable_id}\n{frame['title']}",
|
||||
"rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;"
|
||||
"fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;",
|
||||
x + 110 + col * 280,
|
||||
y + 245 + row * 155,
|
||||
220,
|
||||
82,
|
||||
)
|
||||
|
||||
for index, transition in enumerate(transitions):
|
||||
edge(
|
||||
root,
|
||||
f"{prefix}-edge-{index}",
|
||||
transition["label"],
|
||||
node_ids[transition["from"]],
|
||||
node_ids[transition["to"]],
|
||||
transition.get("kind", "primary"),
|
||||
)
|
||||
|
||||
|
||||
def spec_frame(root: ET.Element, prefix: str, number: int, frame: dict, x: int, y: int) -> None:
|
||||
stable_id, title = frame["id"], frame["title"]
|
||||
cell(root, f"{prefix}-label", f"{number:02d} · {stable_id} · {title}", STYLE["label"], x, y, 1300, 40)
|
||||
top = y + 55
|
||||
cell(root, f"{prefix}-bg", "", STYLE["bg"], x, top, FRAME_W, FRAME_H)
|
||||
cell(root, f"{prefix}-title", title, STYLE["title"], x + 80, top + 55, 900, 50)
|
||||
cell(root, f"{prefix}-head", "稳定 ID 名称/条件 角色 动作/规则 结果", STYLE["table_head"], x + 80, top + 150, 1440, 58)
|
||||
for row in range(4):
|
||||
cell(root, f"{prefix}-row-{row}", "待填写 待填写 待填写 待填写 待填写", STYLE["table_row"], x + 80, top + 208 + row * 66, 1440, 66)
|
||||
cell(root, f"{prefix}-meta", f"规格 ID:{stable_id}|所有引用使用页面/状态/动作稳定 ID", STYLE["meta"], x + 80, top + 835, 1440, 30)
|
||||
|
||||
|
||||
def load_spec(path: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"Cannot read spec: {exc}") from exc
|
||||
if not isinstance(data.get("pages"), list) or not data["pages"]:
|
||||
raise ValueError("spec.pages must be a non-empty list")
|
||||
columns = data.get("columns", 3)
|
||||
if not isinstance(columns, int) or not 1 <= columns <= 5:
|
||||
raise ValueError("columns must be an integer from 1 to 5")
|
||||
seen: set[str] = set()
|
||||
for page in data["pages"]:
|
||||
if page.get("kind") not in {"screen", "flow", "spec"}:
|
||||
raise ValueError(f"Unsupported page kind: {page.get('kind')!r}")
|
||||
if not page.get("name") or not isinstance(page.get("frames"), list) or not page["frames"]:
|
||||
raise ValueError("each page needs name and a non-empty frames list")
|
||||
for frame in page["frames"]:
|
||||
if not frame.get("id") or not frame.get("title"):
|
||||
raise ValueError("each frame needs id and title")
|
||||
if frame["id"] in seen:
|
||||
raise ValueError(f"duplicate stable frame id: {frame['id']}")
|
||||
seen.add(frame["id"])
|
||||
transitions = data.get("transitions", [])
|
||||
if not isinstance(transitions, list):
|
||||
raise ValueError("transitions must be a list")
|
||||
for transition in transitions:
|
||||
if not all(transition.get(key) for key in ("from", "to", "label")):
|
||||
raise ValueError("each transition needs from, to and label")
|
||||
if transition["from"] not in seen or transition["to"] not in seen:
|
||||
raise ValueError(f"transition references unknown frame: {transition}")
|
||||
if transition.get("kind", "primary") not in EDGE_COLORS:
|
||||
raise ValueError(f"unsupported transition kind: {transition.get('kind')!r}")
|
||||
return data
|
||||
|
||||
|
||||
def build(spec: dict) -> ET.ElementTree:
|
||||
mxfile = ET.Element("mxfile", {
|
||||
"host": "app.diagrams.net", "agent": "draw-prototype-flow", "version": "24.7.17",
|
||||
"type": "device", "compressed": "false", "pages": str(len(spec["pages"]))
|
||||
})
|
||||
product = spec.get("title", "产品名称")
|
||||
columns = spec.get("columns", 3)
|
||||
frames_by_id = {
|
||||
frame["id"]: frame
|
||||
for page in spec["pages"]
|
||||
for frame in page["frames"]
|
||||
}
|
||||
number = 0
|
||||
for page_index, page in enumerate(spec["pages"]):
|
||||
diagram = ET.SubElement(mxfile, "diagram", {
|
||||
"id": f"page-{page_index}-{safe_id(page['name'])}", "name": page["name"]
|
||||
})
|
||||
model = ET.SubElement(diagram, "mxGraphModel", {
|
||||
"dx": "1200", "dy": "800", "grid": "1", "gridSize": "10", "guides": "1",
|
||||
"tooltips": "1", "connect": "1", "arrows": "1", "fold": "1", "page": "0",
|
||||
"pageScale": "1", "math": "0", "shadow": "0"
|
||||
})
|
||||
root = ET.SubElement(model, "root")
|
||||
ET.SubElement(root, "mxCell", {"id": "0"})
|
||||
ET.SubElement(root, "mxCell", {"id": "1", "parent": "0"})
|
||||
for frame_index, frame in enumerate(page["frames"]):
|
||||
number += 1
|
||||
col, row = frame_index % columns, frame_index // columns
|
||||
x, y = col * (FRAME_W + GAP_X), row * (FRAME_H + GAP_Y) + 15
|
||||
prefix = f"p{page_index}-f{frame_index}-{safe_id(frame['id'])}"
|
||||
if page["kind"] == "screen":
|
||||
screen_frame(root, prefix, number, frame, x, y, product)
|
||||
elif page["kind"] == "flow":
|
||||
flow_frame(root, prefix, number, frame, x, y)
|
||||
else:
|
||||
spec_frame(root, prefix, number, frame, x, y)
|
||||
if page["kind"] == "flow" and spec.get("transitions"):
|
||||
flow_graph(root, f"p{page_index}-auto-flow", spec["transitions"], frames_by_id, 0, 15)
|
||||
ET.indent(mxfile, space=" ")
|
||||
return ET.ElementTree(mxfile)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
options = args()
|
||||
if options.output.exists() and not options.force:
|
||||
print(f"Refusing to overwrite existing file: {options.output}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
spec = load_spec(options.spec)
|
||||
tree = build(spec)
|
||||
options.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
tree.write(options.output, encoding="utf-8", xml_declaration=True)
|
||||
except (ValueError, OSError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
frame_count = sum(len(page["frames"]) for page in spec["pages"])
|
||||
print(f"Created {options.output}: {len(spec['pages'])} page(s), {frame_count} frame(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the structure of an uncompressed Draw.io product prototype."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
import zlib
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRAME_RE = re.compile(r"^(?:\d{2,3}|P-[A-Z0-9-]+)\s*(?:[·.\-—-]|\s)")
|
||||
NUMERIC_REF_RE = re.compile(r"(?:下一步|关联|进入|见|去向)[^|\n]{0,40}(?<!\d)\d{2,3}(?!\d)")
|
||||
FLOW_PAGE_RE = re.compile(r"(?:总览|流程|动线|flow)", re.I)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
level: str
|
||||
code: str
|
||||
message: str
|
||||
page: str | None = None
|
||||
cell: str | None = None
|
||||
|
||||
|
||||
def label_text(value: str | None) -> str:
|
||||
value = html.unescape(value or "")
|
||||
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
|
||||
value = re.sub(r"<[^>]+>", "", value)
|
||||
return " ".join(value.replace("\xa0", " ").split())
|
||||
|
||||
|
||||
def is_frame_title(cell: ET.Element) -> bool:
|
||||
"""Recognize large external frame headings, not numbered table content."""
|
||||
if not FRAME_RE.match(label_text(cell.get("value"))):
|
||||
return False
|
||||
geometry = cell.find("mxGeometry")
|
||||
if geometry is None:
|
||||
return False
|
||||
try:
|
||||
width = float(geometry.get("width", "0"))
|
||||
height = float(geometry.get("height", "0"))
|
||||
except ValueError:
|
||||
return False
|
||||
style = cell.get("style", "")
|
||||
size_match = re.search(r"(?:^|;)fontSize=(\d+(?:\.\d+)?)(?:;|$)", style)
|
||||
font_size = float(size_match.group(1)) if size_match else 0
|
||||
return width >= 500 and height <= 80 and font_size >= 20
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("file", type=Path, help="Uncompressed .drawio file")
|
||||
parser.add_argument("--min-frames", type=int, default=0)
|
||||
parser.add_argument("--min-edges", type=int, default=0)
|
||||
parser.add_argument("--strict", action="store_true", help="Treat warnings as failures")
|
||||
parser.add_argument("--json", action="store_true", dest="as_json")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def graph_model(diagram: ET.Element) -> tuple[ET.Element | None, bool]:
|
||||
"""Return a graph model and whether the page used Draw.io compression."""
|
||||
model = diagram.find("mxGraphModel")
|
||||
if model is not None:
|
||||
return model, False
|
||||
payload = (diagram.text or "").strip()
|
||||
if not payload:
|
||||
return None, False
|
||||
try:
|
||||
decoded = base64.b64decode(payload)
|
||||
xml_text = urllib.parse.unquote(zlib.decompress(decoded, -15).decode("utf-8"))
|
||||
model = ET.fromstring(xml_text)
|
||||
except (ValueError, UnicodeDecodeError, zlib.error, ET.ParseError):
|
||||
return None, True
|
||||
return (model if model.tag == "mxGraphModel" else None), True
|
||||
|
||||
|
||||
def validate(path: Path, min_frames: int, min_edges: int) -> tuple[dict, list[Issue]]:
|
||||
issues: list[Issue] = []
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
return {"file": str(path), "pages": []}, [Issue("error", "xml", str(exc))]
|
||||
|
||||
if root.tag != "mxfile":
|
||||
issues.append(Issue("error", "root", f"Expected mxfile root, found {root.tag!r}"))
|
||||
|
||||
diagrams = root.findall("diagram")
|
||||
if not diagrams:
|
||||
issues.append(Issue("error", "pages", "No diagram pages found"))
|
||||
|
||||
names = [diagram.get("name", "") for diagram in diagrams]
|
||||
for name in sorted(set(names)):
|
||||
if name and names.count(name) > 1:
|
||||
issues.append(Issue("warning", "duplicate-page-name", f"Duplicate page name: {name}"))
|
||||
|
||||
page_reports = []
|
||||
total_frames = 0
|
||||
total_edges = 0
|
||||
|
||||
for index, diagram in enumerate(diagrams, start=1):
|
||||
page_name = diagram.get("name") or f"page-{index}"
|
||||
model, compressed = graph_model(diagram)
|
||||
if model is None:
|
||||
issues.append(Issue(
|
||||
"error",
|
||||
"compressed-page",
|
||||
"Page has no readable mxGraphModel",
|
||||
page_name,
|
||||
))
|
||||
page_reports.append({"name": page_name, "vertices": 0, "edges": 0, "frames": 0})
|
||||
continue
|
||||
if compressed:
|
||||
issues.append(Issue(
|
||||
"warning",
|
||||
"compressed-page",
|
||||
"Compressed page is readable, but agent-generated output should use compressed=false",
|
||||
page_name,
|
||||
))
|
||||
|
||||
graph_root = model.find("root")
|
||||
if graph_root is None:
|
||||
issues.append(Issue("error", "graph-root", "Missing graph root", page_name))
|
||||
continue
|
||||
|
||||
cells = graph_root.findall("mxCell")
|
||||
by_id: dict[str, ET.Element] = {}
|
||||
for cell in cells:
|
||||
cell_id = cell.get("id")
|
||||
if not cell_id:
|
||||
issues.append(Issue("error", "missing-id", "mxCell has no id", page_name))
|
||||
continue
|
||||
if cell_id in by_id:
|
||||
issues.append(Issue("error", "duplicate-id", f"Duplicate cell id: {cell_id}", page_name, cell_id))
|
||||
by_id[cell_id] = cell
|
||||
|
||||
vertices = [cell for cell in cells if cell.get("vertex") == "1"]
|
||||
edges = [cell for cell in cells if cell.get("edge") == "1"]
|
||||
frame_cells = [cell for cell in vertices if is_frame_title(cell)]
|
||||
|
||||
for cell in cells:
|
||||
cell_id = cell.get("id")
|
||||
parent = cell.get("parent")
|
||||
if parent and parent not in by_id:
|
||||
issues.append(Issue("error", "dangling-parent", f"Unknown parent {parent}", page_name, cell_id))
|
||||
if (cell.get("vertex") == "1" or cell.get("edge") == "1") and cell.find("mxGeometry") is None:
|
||||
issues.append(Issue("error", "geometry", "Vertex/edge has no mxGeometry", page_name, cell_id))
|
||||
if cell.get("vertex") == "1" and NUMERIC_REF_RE.search(label_text(cell.get("value"))):
|
||||
issues.append(Issue(
|
||||
"warning",
|
||||
"numeric-cross-reference",
|
||||
"Cross-reference appears to use a display number; use a stable page/state ID",
|
||||
page_name,
|
||||
cell_id,
|
||||
))
|
||||
|
||||
for edge in edges:
|
||||
edge_id = edge.get("id")
|
||||
source = edge.get("source")
|
||||
target = edge.get("target")
|
||||
if not source or not target:
|
||||
issues.append(Issue("error", "unbound-edge", "Edge needs both source and target", page_name, edge_id))
|
||||
continue
|
||||
if source not in by_id:
|
||||
issues.append(Issue("error", "dangling-source", f"Unknown source {source}", page_name, edge_id))
|
||||
if target not in by_id:
|
||||
issues.append(Issue("error", "dangling-target", f"Unknown target {target}", page_name, edge_id))
|
||||
if not label_text(edge.get("value")):
|
||||
issues.append(Issue("warning", "unlabelled-edge", "User-flow edge has no action/result label", page_name, edge_id))
|
||||
|
||||
if len(frame_cells) > 15:
|
||||
issues.append(Issue(
|
||||
"warning",
|
||||
"crowded-page",
|
||||
f"{len(frame_cells)} numbered frames on one page; consider splitting into multiple pages",
|
||||
page_name,
|
||||
))
|
||||
if FLOW_PAGE_RE.search(page_name) and not edges:
|
||||
issues.append(Issue("warning", "no-flow", "Flow/overview page has no linked user-flow edges", page_name))
|
||||
|
||||
total_frames += len(frame_cells)
|
||||
total_edges += len(edges)
|
||||
page_reports.append({
|
||||
"name": page_name,
|
||||
"cells": len(cells),
|
||||
"vertices": len(vertices),
|
||||
"edges": len(edges),
|
||||
"frames": len(frame_cells),
|
||||
})
|
||||
|
||||
if total_frames < min_frames:
|
||||
issues.append(Issue("error", "min-frames", f"Found {total_frames} frames; expected at least {min_frames}"))
|
||||
if total_edges < min_edges:
|
||||
issues.append(Issue("error", "min-edges", f"Found {total_edges} edges; expected at least {min_edges}"))
|
||||
|
||||
return {
|
||||
"file": str(path),
|
||||
"page_count": len(diagrams),
|
||||
"frame_count": total_frames,
|
||||
"edge_count": total_edges,
|
||||
"pages": page_reports,
|
||||
}, issues
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
report, issues = validate(args.file, args.min_frames, args.min_edges)
|
||||
errors = sum(issue.level == "error" for issue in issues)
|
||||
warnings = sum(issue.level == "warning" for issue in issues)
|
||||
|
||||
if args.as_json:
|
||||
print(json.dumps({**report, "issues": [asdict(issue) for issue in issues]}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(f"{report['file']}: {report.get('page_count', 0)} page(s), "
|
||||
f"{report.get('frame_count', 0)} frame(s), {report.get('edge_count', 0)} edge(s)")
|
||||
for page in report.get("pages", []):
|
||||
print(f" {page['name']}: {page.get('frames', 0)} frames, "
|
||||
f"{page.get('vertices', 0)} vertices, {page.get('edges', 0)} edges")
|
||||
for issue in issues:
|
||||
where = " / ".join(value for value in (issue.page, issue.cell) if value)
|
||||
suffix = f" ({where})" if where else ""
|
||||
print(f" {issue.level.upper()} [{issue.code}] {issue.message}{suffix}")
|
||||
print(f"Result: {errors} error(s), {warnings} warning(s)")
|
||||
|
||||
return 1 if errors or (args.strict and warnings) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,13 +1,13 @@
|
||||
# manage-release
|
||||
|
||||
`manage-release` 帮助 Agent 管理从开发版本到发布的 Git 流程,包括 worktree、分支、
|
||||
版本号、PR/MR、合并和 release tag。它可以只处理其中一个阶段,也可以从准备开发目录
|
||||
一直执行到发布。
|
||||
`manage-release` 帮助 Agent 发布已经准备好的 Git commit,也能在发布前确有必要时管理
|
||||
版本文件、worktree、分支、PR/MR 和合并。普通发布优先走“更新说明 + annotated tag”的
|
||||
短路径;只有发布要求修改仓库内容时,才进入准备版本的分支流程。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- 项目已经是 Git 仓库,并配置了可访问的远端。
|
||||
- 项目已有版本文件或发布约定;没有时可以让 Agent 先给出版本建议。
|
||||
- 项目已有版本来源或 tag 发布约定;没有时可以让 Agent 先给出版本建议。
|
||||
- 创建或合并 PR/MR、创建 Forge Release 时,准备好 GitHub、GitLab、Gitea 或 Forgejo
|
||||
对应的已认证 CLI。只发布 Git tag 时不要求 Forge CLI,但 Git remote 必须可读写。
|
||||
- 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作
|
||||
@@ -54,11 +54,21 @@ Agent 只会在仓库要求全部满足后合并,不会使用管理员权限
|
||||
### 为已合并版本发布 tag
|
||||
|
||||
```text
|
||||
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。确认 tag 指向包含该版本号的 merged commit,不要创建 Forge Release。
|
||||
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。根据 v1.5.0 到目标 commit 的实际变化生成更新说明,把说明写入 annotated tag,并验证远端 tag 的 commit 和说明。不要创建 Forge Release。
|
||||
```
|
||||
|
||||
适合依靠 Git tag 触发后续 CI 发布的项目。Agent 会分别验证版本文件、目标 commit 和
|
||||
远端 tag,不会给未合并分支打正式 tag。
|
||||
这是无需修改仓库文件时的默认短路径,适合依靠 Git tag 触发后续 CI 发布的项目。Agent
|
||||
会分别验证目标 commit、更新说明和远端 tag,不会创建 worktree、发布分支或 PR,也不会
|
||||
给未合并分支打正式 tag。
|
||||
|
||||
### 为已合并版本创建 Gitea Release
|
||||
|
||||
```text
|
||||
使用 manage-release,发布已经合并到 main 的 1.6.0:生成更新说明,创建并验证附带说明的 v1.6.0 annotated tag,然后在 Gitea 创建使用同一份说明的 Release。
|
||||
```
|
||||
|
||||
Agent 会先证明 tag 已存在、指向计划 commit 且包含更新说明,再创建 Gitea Release。
|
||||
Release 创建失败时保留正确的 tag,从 Release 阶段恢复。
|
||||
|
||||
### 完成端到端发布
|
||||
|
||||
@@ -66,8 +76,9 @@ Agent 只会在仓库要求全部满足后合并,不会使用管理员权限
|
||||
使用 manage-release 完成 1.6.0 发布:从 main 创建 worktree 和分支,完成版本更新和验证,创建 PR,检查通过后合并,推送 v1.6.0 tag,并创建 Forge Release。
|
||||
```
|
||||
|
||||
这条请求授权完整流程。遇到 review 未通过、CI 失败、版本冲突或已有同名 tag 时,
|
||||
Agent 会停止并说明卡在哪一步,不会绕过检查继续发布。
|
||||
只有版本文件或仓库内 CHANGELOG 必须随发布修改时才使用这条长路径。tag 仍必须附带更新
|
||||
说明,Forge Release 复用同一份说明。遇到 review 未通过、CI 失败、版本冲突或已有同名
|
||||
tag 时,Agent 会停止并说明卡在哪一步,不会绕过检查继续发布。
|
||||
|
||||
### 发布紧急修复版本
|
||||
|
||||
@@ -89,18 +100,19 @@ Skill 会从 Git 和代码托管平台重新判断状态,再继续缺失的步
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
Agent 会先发现项目自己的版本、分支和发布规则,再检查本地 Git 与远端状态。它只执行
|
||||
请求中明确授权的阶段,并在合并、tag 或 release 条件不满足时停止。完整流程结束后,
|
||||
结果中会分别列出 worktree、分支、版本、PR/MR、merged commit、tag 和 Forge Release
|
||||
状态。
|
||||
Agent 会先发现项目自己的版本和发布规则、锁定远端目标 commit,再判断发布是否要求修改
|
||||
仓库内容。不需要修改时直接生成更新说明并发布 annotated tag;需要修改时才准备分支,
|
||||
并只在隔离用户工作确有必要时创建 worktree。它只执行请求中明确授权的阶段,并在合并、
|
||||
tag 或 release 条件不满足时停止。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
- 只分析版本时,结果包含建议版本及依据。
|
||||
- 创建开发环境时,结果包含 worktree 路径、分支和基线 commit。
|
||||
- 创建或合并 PR/MR 时,结果包含 URL、检查状态和 merged commit。
|
||||
- 发布 tag 时,远端 tag 解引用后的 commit 与 merged commit 一致。
|
||||
- 创建 Forge Release 时,结果包含可访问的 release URL。
|
||||
- 发布 tag 时,远端 tag 解引用后的 commit 与目标 commit 一致,而且 tag object 包含经过
|
||||
核对的更新说明。
|
||||
- 创建 Forge Release 时,结果包含可访问的 release URL,正文与 tag 更新说明一致。
|
||||
- 中途停止时,结果说明停在哪一步、为什么停止,以及下次如何继续。
|
||||
|
||||
## 不适用的场景
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
name: manage-release
|
||||
description: >-
|
||||
管理 Git 项目从开发版本到发布的完整生命周期:发现并遵循项目分支与版本策略,
|
||||
创建或复用隔离 worktree 和版本分支,确定并同步版本号,推送并创建、检查或合并
|
||||
PR/MR,在合并后的准确提交上创建并推送 release tag,并按需创建 Forge Release、
|
||||
恢复中断流程或安全清理。用户要求开始发布相关的新版本、为发布开 worktree 或分支、
|
||||
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
|
||||
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
|
||||
DEB/Docker 等产物、管理仓库权限时不使用。
|
||||
管理 Git 项目从已准备 commit 到版本发布的生命周期:发现并遵循项目版本与发布策略,
|
||||
优先为远端已验证 commit 生成更新说明并创建 annotated release tag,按需创建 Forge
|
||||
Release;只有必须修改版本文件或仓库内发布说明时,才创建或复用版本分支、worktree
|
||||
和 PR/MR。也支持恢复中断流程与安全清理。用户要求发布版本、打 release tag、创建
|
||||
平台 Release、准备版本变更、处理 hotfix 或继续未完成发布时使用。只做普通编码、普通
|
||||
worktree 或 PR/MR 操作、代码审查、构建或上传 DEB/Docker 等产物、管理仓库权限时不使用。
|
||||
---
|
||||
|
||||
# Manage Release
|
||||
@@ -40,9 +39,11 @@ description: >-
|
||||
项目策略只描述仓库期望的做法,不能替用户授予 push、创建或合并 PR/MR、推送 tag、
|
||||
创建 Forge Release、删除分支等外部写权限。
|
||||
|
||||
“完整处理并发布 v1.2.3”可以授权从准备到验证远端 tag 的流程,不要在每一步重复询问;
|
||||
它不自动包含 Forge Release 或分支清理。若版本、base、tag、远端或合并方式是推断所得,
|
||||
或者执行中目标发生变化,在第一次远端写操作前展示准确目标并获得确认。
|
||||
“发布 v1.2.3”允许为已经准备好的远端 commit 生成更新说明、创建并推送 annotated tag,
|
||||
再验证远端 tag 的 commit 和说明。“完整处理并发布 v1.2.3”还允许在仓库内容必须修改时
|
||||
走版本分支、PR/MR 和合并流程。两者都不自动包含 Forge Release 或分支清理。若版本、
|
||||
base、目标 commit、tag、远端或合并方式是推断所得,或者执行中目标发生变化,在第一次
|
||||
远端写操作前展示准确目标和更新说明并获得确认。
|
||||
|
||||
## 工作流
|
||||
|
||||
@@ -56,8 +57,10 @@ description: >-
|
||||
`git worktree list --porcelain`。
|
||||
3. 获取远端最新引用后,记录 base 分支的准确 commit。不要把本地过期分支当作发布基线。
|
||||
4. 识别版本来源、最近稳定 tag、tag 格式、分支命名、合并方式和发布说明来源。
|
||||
5. 分别检查 Git remote 的读取与推送能力,以及 PR/MR、合并或 Forge Release 阶段所需的
|
||||
GitHub、GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
|
||||
5. 分别用 `git remote get-url --all origin` 和
|
||||
`git remote get-url --push --all origin` 检查读取与推送目标;远端写操作要求两者各只有
|
||||
一个值且规范化后指向同一仓库。再检查 PR/MR、合并或 Forge Release 阶段所需的 GitHub、
|
||||
GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
|
||||
记录匹配该 host 的活动账号;认证检查只读取状态。
|
||||
6. 查询已有分支、worktree、PR/MR、tag 和 Forge Release,从真实状态判断流程已走到哪一步。
|
||||
|
||||
@@ -68,25 +71,47 @@ description: >-
|
||||
|
||||
在修改前确定并展示:
|
||||
|
||||
- base 分支及其远端 commit。
|
||||
- 当前版本、规范化后的目标版本、渲染出的分支名和 tag,以及升级依据。
|
||||
- 分支名与 worktree 路径。
|
||||
- base 分支、远端 commit,以及本次 tag 将指向的准确目标 commit。
|
||||
- 当前版本、规范化后的目标版本、tag,以及升级依据;需要准备仓库修改时再渲染分支名。
|
||||
- 从上一个稳定 tag 到目标 commit 的更新说明范围、来源和拟发布内容。
|
||||
- 是否需要修改仓库内容;若需要,再列出分支名与 worktree 路径。
|
||||
- 需要执行的本地验证和远端检查。
|
||||
- 合并方式、tag、是否创建 Forge Release。
|
||||
- 用户已授权的最远阶段和清理范围。
|
||||
|
||||
项目没有约定时,单一协调版本使用 SemVer、`release/v<version>` 分支、
|
||||
`v<version>` annotated tag。仓库只启用一种合并方式时使用该方式;存在多种方式且没有
|
||||
项目规则时默认 squash。默认不删除远端分支,不自动创建 Forge Release。
|
||||
项目没有约定时,单一协调版本使用 SemVer 和 `v<version>` annotated tag。只有发布前
|
||||
必须修改仓库内容时才使用 `release/v<version>` 分支;仓库只启用一种合并方式时使用该
|
||||
方式,存在多种方式且没有项目规则时默认 squash。默认不删除远端分支,不自动创建
|
||||
Forge Release。
|
||||
|
||||
把用户输入拆成“规范版本”和“展示名称”:默认 SemVer 的规范版本是无 `v` 前缀的
|
||||
`1.6.0`,版本文件和 `{version}` 都使用该值;分支与 tag 再分别按 pattern 渲染为
|
||||
`release/v1.6.0` 和 `v1.6.0`。用户输入 `v1.6.0` 时先规范化,不要产生双前缀。
|
||||
|
||||
### 3. 创建或复用隔离 worktree
|
||||
### 3. 选择直接发布或准备版本
|
||||
|
||||
优先使用当前环境提供的 worktree 管理器;项目仓库中的管理脚本只有在可信 base 已声明
|
||||
且经过检查时才能使用,没有时使用原生 Git。创建前验证:
|
||||
先计算发布所需的仓库内容差异,不要为了遵循固定模板而创建分支或 worktree。满足以下
|
||||
条件时走直接发布路径,跳过第 4 至 7 节:
|
||||
|
||||
- 目标 commit 已作为准确远端引用读取,并可从计划的 base 或项目允许的维护分支到达。
|
||||
- 项目权威版本文件若存在,目标 commit 中已经是目标版本;或项目明确以 tag 作为版本来源。
|
||||
- 项目不要求把本次 CHANGELOG 或发布说明提交回仓库。
|
||||
- 项目要求的构建、测试和发布检查已经通过。
|
||||
|
||||
直接发布不 checkout 目标 commit,不创建分支、worktree、版本提交或 PR/MR。更新说明是
|
||||
tag object 和可选 Forge Release 的内容,不因生成说明本身进入仓库修改路径。
|
||||
|
||||
只要版本文件、生成元数据或仓库内 CHANGELOG 必须修改,就进入准备版本路径。优先复用
|
||||
身份准确、未被占用且适合本次发布的现有分支;没有时才创建版本分支。优先复用安全且
|
||||
干净的现有 worktree;当前目录承载用户工作、目标分支已在别处使用或需要隔离时,才创建
|
||||
linked worktree。用户只授权了直接发布时,在展示必要差异后停止,不要擅自扩大到修改、
|
||||
分支或 PR/MR 流程。
|
||||
|
||||
### 4. 创建或复用准备版本的分支和 worktree
|
||||
|
||||
仅在第 3 节判定需要准备仓库修改时执行本节。优先使用当前环境提供的 worktree 管理器;
|
||||
项目仓库中的管理脚本只有在可信 base 已声明且经过检查时才能使用,没有时使用原生 Git。
|
||||
创建前验证:
|
||||
|
||||
- 目标分支和目录未被其他 worktree 占用。
|
||||
- 目标目录不是 `/`、用户主目录、仓库根目录或已有非空目录。
|
||||
@@ -103,7 +128,7 @@ worktree 占用的分支。
|
||||
相对计划 base 的提交,确认是同一次发布后用 `git worktree add <path> <branch>` 挂载;
|
||||
身份不匹配或来源不明时停止,不要用 `-b` 覆盖或另建同名分支。
|
||||
|
||||
### 4. 开发并同步版本
|
||||
### 5. 修改并同步版本
|
||||
|
||||
在目标 worktree 内完成用户要求的修改。根据项目规则更新所有权威版本来源和
|
||||
CHANGELOG 或发布说明,保证它们进入同一个 PR/MR。运行项目声明的格式化、测试、构建
|
||||
@@ -117,16 +142,18 @@ CHANGELOG 或发布说明,保证它们进入同一个 PR/MR。运行项目声
|
||||
提交前重新读取 HEAD 和完整工作区状态,只提交本次范围内的文件。不要在未合并分支上
|
||||
创建正式 release tag。
|
||||
|
||||
### 5. 推送并创建 PR/MR
|
||||
### 6. 推送并创建 PR/MR
|
||||
|
||||
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围。Git 分支与
|
||||
tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用与托管平台匹配的官方 CLI。
|
||||
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围,并重新读取
|
||||
唯一 fetch/push URL。Git 分支与 tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用
|
||||
与托管平台匹配的官方 CLI。直接执行当前 shell 中的 `git` 和平台 CLI,让工具使用其正常
|
||||
登录;不要读取、解析或复制 `~/.gitconfig`、tea/gh/glab 配置,也不要从中提取 token。
|
||||
不要让 CLI 隐式创建 fork、改变 base 或选择另一个 remote。
|
||||
|
||||
PR/MR 内容至少说明目标版本、变更摘要、验证命令和结果、发布后续动作。用户只要求开
|
||||
PR/MR 时,停在这里并返回 URL、head/base、当前检查状态和阻塞项。
|
||||
|
||||
### 6. 检查并合并
|
||||
### 7. 检查并合并
|
||||
|
||||
从托管平台重新读取 PR/MR 状态。仅在下列条件全部满足时合并:
|
||||
|
||||
@@ -139,29 +166,40 @@ PR/MR 时,停在这里并返回 URL、head/base、当前检查状态和阻塞
|
||||
不要使用管理员绕过、直接推送受保护 base,或为通过检查而修改保护规则。合并后获取
|
||||
平台确认的 merged commit,更新远端 base,并验证该 commit 可从远端 base 到达。
|
||||
|
||||
### 7. 创建并发布 tag
|
||||
### 8. 创建并发布带更新说明的 tag
|
||||
|
||||
在 merged commit 上核对目标版本后,再查询一次远端同名 tag。tag 不存在时创建
|
||||
annotated tag;项目要求签名时,必须先确认签名工具和密钥可用,创建 signed tag,并在
|
||||
本地验证签名成功后才允许 push。签名不可用或验证失败时停止,不得降级为 unsigned tag。
|
||||
显式指定 merged commit,并只推送这个 tag。
|
||||
准备版本路径使用平台确认且可从远端 base 到达的 merged commit;直接发布路径使用计划
|
||||
中锁定且重新验证过的远端目标 commit。两者统一记为 release commit。核对目标版本后,
|
||||
再查询一次远端同名 tag。
|
||||
|
||||
创建 tag 前,根据上一个稳定 tag 到 release commit 的实际差异生成更新说明;首次发布则
|
||||
使用项目声明的发布基线,没有声明时核对完整可达历史并明确标记首次发布。说明概括实际
|
||||
存在的新增、变更、修复、维护或文档变化;存在破坏性变化、迁移步骤或已知限制时必须明确
|
||||
列出。每项内容都应能追溯到本次提交、PR/MR 或项目 CHANGELOG。空说明、只重复版本号、
|
||||
模板占位文字或无法由实际变化支持的内容都不能发布。
|
||||
|
||||
tag 不存在时,使用完整更新说明创建 annotated tag;项目要求签名时,必须先确认签名
|
||||
工具和密钥可用,创建 signed annotated tag,并在本地验证签名成功后才允许 push。使用
|
||||
文件输入完整的多行说明,显式指定 release commit,并只推送这个 tag。禁止创建 lightweight
|
||||
release tag。签名不可用或验证失败时停止,不得降级为 unsigned tag。
|
||||
|
||||
同名远端 tag 已存在且指向其他 commit 时立即停止。不要覆盖、删除或移动已经发布的
|
||||
tag。推送后读取远端 tag,并将 annotated tag 解引用到 commit,确认它与 merged commit
|
||||
完全相同。
|
||||
tag。同名 tag 即使指向 release commit,只要它是 lightweight tag、缺少更新说明或说明与
|
||||
计划不一致,也视为身份不匹配并停止。推送后读取远端 tag object,确认它是 annotated
|
||||
tag,将它解引用到 commit 并核对完整更新说明;commit 和说明必须都与发布计划完全一致。
|
||||
|
||||
仅当用户本次请求明确要求时创建 Forge Release;项目策略只能说明创建方式,不能授予
|
||||
外部写权限。必须引用已经存在并验证过的 tag,禁止让平台从默认分支隐式创建 tag。
|
||||
发布说明中的每项变化都应能追溯到本次 PR/MR、提交或项目 CHANGELOG。
|
||||
Forge Release 正文复用 tag 中经过验证的更新说明,不维护第二份相互独立的发布内容。
|
||||
|
||||
### 8. 验证、恢复和清理
|
||||
### 9. 验证、恢复和清理
|
||||
|
||||
分别报告以下状态,不要用“发布成功”掩盖其中某一步未完成:
|
||||
|
||||
- 版本文件和本地验证。
|
||||
- PR/MR URL、合并状态和 merged commit。
|
||||
- 远端 tag 及其解引用后的 commit。
|
||||
- Forge Release URL 和可见性,若本次要求创建。
|
||||
- 远端 tag 的对象类型、完整更新说明及其解引用后的 commit。
|
||||
- Forge Release URL、可见性及正文一致性,若本次要求创建。
|
||||
- worktree、本地分支和远端分支是否保留。
|
||||
|
||||
清理时只移除干净且已确认不再使用的 linked worktree,不使用强制删除。删除本地或远端
|
||||
@@ -172,12 +210,15 @@ tag。推送后读取远端 tag,并将 annotated tag 解引用到 commit,确
|
||||
遇到以下任一情况时停止相应写操作并说明恢复路径:
|
||||
|
||||
- base、目标版本、版本来源、remote,或请求阶段所需的托管平台无法唯一确定。
|
||||
- 直接发布的目标 commit 无法从计划远端分支到达,或项目要求的发布检查未通过。
|
||||
- 项目策略包含未知字段、错误类型、仓库外路径或不安全 ref。
|
||||
- 多个权威版本来源不一致。
|
||||
- 目标分支、worktree、PR/MR、tag 或 release 已存在但身份不匹配。
|
||||
- required checks、review 或冲突状态不满足合并规则。
|
||||
- 合并结果的准确 commit 无法从平台确认,或无法从远端 base 到达。
|
||||
- 远端 tag 已指向其他 commit。
|
||||
- 更新说明为空、只有版本号、包含占位内容、无法追溯到实际变化,或远端 tag 不是包含
|
||||
计划说明的 annotated tag。
|
||||
- 请求的 PR/MR、合并或 Forge Release 阶段所需的平台 CLI 缺失、未认证或无法读取状态。
|
||||
- 项目要求 tag 签名,但签名能力不可用或本地签名验证失败。
|
||||
- 操作需要 force push、管理员绕过、移动已发布 tag 或覆盖用户工作。
|
||||
@@ -188,7 +229,9 @@ tag。推送后读取远端 tag,并将 annotated tag 解引用到 commit,确
|
||||
|
||||
## 完成标准
|
||||
|
||||
只分析时,给出当前状态、建议版本、依据和下一步。开始版本时,给出 worktree 路径、
|
||||
分支和基线 commit。创建 PR/MR 时,给出 URL 与检查状态。合并时,给出 merged commit。
|
||||
发布 tag 时,证明远端 tag 解引用到该 commit。创建 Forge Release 时,再给出 release URL
|
||||
与可见性。任何部分未完成都要标明阻塞阶段和可恢复动作。
|
||||
只分析时,给出当前状态、建议版本、依据和下一步。直接发布时,给出目标 commit 和更新
|
||||
说明,不得虚构 worktree 或分支步骤。开始准备版本时,给出所用 worktree、分支和基线
|
||||
commit;未新建 worktree 时明确说明复用了哪个安全工作区。创建 PR/MR 时,给出 URL 与
|
||||
检查状态。合并时,给出 merged commit。发布 tag 时,证明远端 tag 是包含计划更新说明的
|
||||
annotated tag,并解引用到 release commit。创建 Forge Release 时,再给出 release URL、
|
||||
可见性与正文一致性。任何部分未完成都要标明阻塞阶段和可恢复动作。
|
||||
|
||||
@@ -21,6 +21,10 @@ CLI。只发布 tag 时,不要因为缺少 Forge CLI 而停止;仍要验证
|
||||
个账号或 profile 时,必须唯一确定本次使用的身份。CLI 版本之间的 flags 可能不同,
|
||||
每次执行写操作前读取对应子命令的 `--help`,不要凭记忆拼接参数。
|
||||
|
||||
直接从目标 worktree 的当前 shell 运行 `git` 与平台 CLI,让它们使用各自正常的登录机制。
|
||||
Gitea/Forgejo 使用 `tea whoami` 和 `tea` 对应子命令;不要由 Agent 读取 tea 配置文件、
|
||||
调用 credential helper 导出 token,或把 token 转存到命令参数、环境文件和发布说明。
|
||||
|
||||
当请求确实涉及 PR/MR、合并或 Forge Release,且 remote host 无法识别、CLI 缺失、认证
|
||||
失败或 CLI 指向另一个实例时,停止对应平台阶段并报告:
|
||||
|
||||
@@ -64,14 +68,16 @@ commit 已进入远端 base。只看到本地 merge commit 或分支关闭不足
|
||||
|
||||
## 创建 Forge Release
|
||||
|
||||
把 Git tag 和 Forge Release 当成两个独立状态。先创建、推送并验证 tag,再创建 release。
|
||||
把 Git tag 和 Forge Release 当成两个独立状态。先创建、推送并验证包含完整更新说明的
|
||||
annotated tag,再创建 release。Forge Release 正文复用已经冻结并验证的 tag 更新说明,
|
||||
不要重新生成另一份内容。
|
||||
|
||||
- GitHub 创建 release 时使用能够拒绝缺失 tag 的选项,例如当前 CLI 支持的
|
||||
`--verify-tag`。
|
||||
- GitLab、Gitea 或 Forgejo 创建 release 前,先用只读命令证明 tag 已存在并指向计划的
|
||||
commit;不要使用会顺便创建 tag 的默认行为。
|
||||
commit、tag object 包含计划的更新说明;不要使用会顺便创建 tag 的默认行为。
|
||||
- 预发布版本按项目规则标记 prerelease,不要自动把它标为 latest 或 stable。
|
||||
- 发布后重新读取 release URL、tag 和可见性。
|
||||
- 发布后重新读取 release URL、tag、正文和可见性,确认正文与 tag 更新说明一致。
|
||||
|
||||
Forge Release 创建失败但 tag 已成功推送时,保留 tag 并从 release 阶段恢复,不要重新
|
||||
合并或创建另一个 tag。
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
|
||||
- `schema`:配置结构版本,当前为 `1`。
|
||||
- `base_branch`:发布 PR/MR 的目标分支。
|
||||
- `branch_pattern`:版本分支格式,支持无展示前缀的规范 `{version}`。
|
||||
- `branch_pattern`:需要准备仓库修改时使用的版本分支格式,支持无展示前缀的规范
|
||||
`{version}`;直接发布不因此创建分支。
|
||||
- `version.scheme`:`semver` 或项目已经使用的其他方案。
|
||||
- `version.sources`:构建和运行实际读取的权威版本文件。
|
||||
- `version.sources`:构建和运行实际读取的权威版本文件;项目明确从 Git tag 派生版本时
|
||||
可以省略,不要为了发布新增无消费方的版本文件。
|
||||
- `tag_pattern`:release tag 格式,支持无展示前缀的规范 `{version}`。
|
||||
- `merge_method`:`squash`、`merge` 或 `rebase`。
|
||||
- `forge_release`:项目是否建议在 tag 后创建 Forge Release;它不授予创建权限。
|
||||
@@ -54,18 +56,20 @@ symlink 后,目标必须仍在当前 worktree 内;权威来源通常还应
|
||||
|
||||
1. 从 remote HEAD 和平台信息确定默认 base,不能确定时询问。
|
||||
2. 从项目文档、CI 和构建入口找版本文件及更新方式。
|
||||
3. 从已有分支和已合并 PR/MR 识别命名与合并方式。
|
||||
4. 从稳定 tag 识别前缀和版本方案,只把 tag 当作交叉验证。
|
||||
5. 无项目约定时使用 `release/v<version>`、SemVer、`v<version>` annotated tag 和
|
||||
squash merge;默认不创建 Forge Release,不删除远端分支。
|
||||
3. 仅当发布需要修改仓库内容时,从已有分支和已合并 PR/MR 识别命名与合并方式。
|
||||
4. 从稳定 tag 识别前缀、版本方案和更新说明范围;项目由 tag 派生版本时,把实际构建
|
||||
入口和 tag 历史共同作为版本来源证据,否则只把 tag 当作交叉验证。
|
||||
5. 无项目约定时使用 SemVer 和 `v<version>` annotated tag;需要准备仓库修改时再使用
|
||||
`release/v<version>` 和 squash merge。默认不创建 Forge Release,不删除远端分支。
|
||||
|
||||
## 每次执行的计划快照
|
||||
|
||||
在第一次写操作前列出以下事实:
|
||||
|
||||
- base 分支和远端 commit。
|
||||
- base 分支、远端 commit 和 tag 将指向的目标 commit。
|
||||
- 当前版本、目标版本、版本来源和升级依据。
|
||||
- worktree 路径与分支名。
|
||||
- 更新说明的提交范围、来源和拟发布内容。
|
||||
- 是否需要修改仓库内容;需要时再列出 worktree 路径与分支名。
|
||||
- PR/MR 托管平台和合并方式。
|
||||
- tag 与 Forge Release 计划。
|
||||
- 用户授权的最远阶段和清理范围。
|
||||
|
||||
@@ -12,12 +12,15 @@ base、head、PR/MR、merged commit、tag 和 release,再执行唯一缺失的
|
||||
|
||||
| 当前状态 | 继续方式 | 禁止事项 |
|
||||
|---|---|---|
|
||||
| 远端 release commit 已准备,没有 tag | 核对版本与更新说明后直接创建 annotated tag | 不创建无必要的分支或 worktree |
|
||||
| 本地 annotated tag 已创建但未推送 | 核对对象类型、commit 和完整说明后只推送该 tag | 不因重入重复创建或改写 tag |
|
||||
| worktree 已创建,无改动 | 继续开发,或经授权移除干净 worktree | 不使用强制删除 |
|
||||
| 本地发布分支已存在,没有 worktree | 核对版本身份、tip、upstream 和 base 后挂载现有分支 | 不用 `-b` 覆盖分支 |
|
||||
| 分支已推送,没有 PR/MR | 确认 head/base 后创建一次 PR/MR | 不重复推送新分支 |
|
||||
| PR/MR 已存在,未合并 | 复用 URL,刷新 checks、review 和冲突状态 | 不创建第二个 PR/MR |
|
||||
| PR/MR 已合并,没有 tag | 获取 merged commit,在该 commit 上继续发布 | 不重新合并 |
|
||||
| tag 已推送,没有 release | 验证 tag 后创建 Forge Release | 不创建替代 tag |
|
||||
| PR/MR 已合并,没有 tag | 获取 merged commit,生成并确认更新说明后继续发布 | 不重新合并 |
|
||||
| tag 已推送,没有 release | 验证 tag 的 commit 和更新说明后创建 Forge Release | 不创建替代 tag |
|
||||
| tag 指向正确 commit 但为 lightweight 或说明不符 | 停止并交由维护者决定撤销或发布修订版本 | 不移动、覆盖或补写远端 tag |
|
||||
| release 已创建,验证未完成 | 读回 release、tag 和可见性 | 不直接声称发布完成 |
|
||||
| 同名资源身份不匹配 | 停止并报告差异 | 不覆盖、关闭或删除未知资源 |
|
||||
|
||||
@@ -35,7 +38,7 @@ merge queue 和 auto-merge。任何 force push 都是硬停止条件。
|
||||
- 未推送的本地版本提交可以在用户授权下修改或放弃,但不要覆盖其他工作。
|
||||
- 已推送但未合并的 PR/MR 可以关闭,分支默认保留。
|
||||
- 已合并变更通过新的 revert PR/MR 回滚,不重写 base 历史。
|
||||
- 已发布 tag 默认不可变。tag 错误时停止,由维护者决定撤销发布或发布新版本。
|
||||
- 已发布 tag 默认不可变。commit 或更新说明错误时停止,由维护者决定撤销发布或发布新版本。
|
||||
- Forge Release 失败不回滚已经正确推送的 tag;从 release 阶段恢复。
|
||||
|
||||
## 清理规则
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
按项目实际构建链路寻找版本来源,不要遍历到一个看起来像版本号的字符串就修改。优先级:
|
||||
|
||||
1. 项目发布文档或 `.release-policy.yaml` 明确声明的文件。
|
||||
2. 构建、打包或运行入口直接读取的清单,例如 `VERSION`、`package.json`、
|
||||
2. 项目明确由 Git tag 或 VCS metadata 派生构建版本时,以 tag 规则作为发布版本来源,
|
||||
不要为了发布凭空新增或修改版本文件。
|
||||
3. 构建、打包或运行入口直接读取的清单,例如 `VERSION`、`package.json`、
|
||||
`pyproject.toml`、`Cargo.toml` 或语言工具链的版本配置。
|
||||
3. 由权威文件生成的镜像文件、锁文件或发布元数据。
|
||||
4. 最近稳定 tag,只用于验证当前版本和发布历史。
|
||||
4. 由权威文件生成的镜像文件、锁文件或发布元数据。
|
||||
5. 最近稳定 tag,用于确定当前已发布版本、更新说明范围和发布历史。
|
||||
|
||||
记录每个权威文件的当前值和更新方式。多个权威来源不一致时停止,不要选择修改时间最新
|
||||
的文件,也不要只改其中一个。
|
||||
|
||||
配置声明的版本文件必须使用仓库相对路径。拒绝绝对路径、`..` 和解析后逃出目标
|
||||
配置声明了版本文件时,它们必须使用仓库相对路径。拒绝绝对路径、`..` 和解析后逃出目标
|
||||
worktree 的 symlink;修改前确认规范化后的准确路径。不要用 glob 或模糊搜索结果执行
|
||||
批量替换。
|
||||
|
||||
@@ -45,10 +47,19 @@ worktree 的 symlink;修改前确认规范化后的准确路径。不要用 gl
|
||||
项目采用自定义前缀或非 SemVer 时,以项目版本来源定义规范值,以 branch/tag pattern
|
||||
定义展示形式。规范化结果无法唯一确定时停止并让用户确认。
|
||||
|
||||
## 判断是否需要同步版本
|
||||
|
||||
直接读取计划 release commit 中的权威版本来源。版本文件已经是目标值,或者项目明确由
|
||||
tag 派生版本,且发布说明不要求写回仓库时,不产生仓库修改,直接进入 tag 发布。
|
||||
|
||||
只有权威版本文件、生成文件或仓库内 CHANGELOG 必须变化时,才进入准备版本流程并使用
|
||||
分支/PR。不要为了制造发布提交而触碰与构建、运行或项目发布规则无关的文件。
|
||||
|
||||
## 同步版本
|
||||
|
||||
在发布分支中一次性更新所有权威版本来源、生成文件和项目要求的 CHANGELOG。运行项目
|
||||
自己的版本更新工具时,先检查它会修改哪些文件,避免隐式发布、提交或上传。
|
||||
仅在判断存在必要仓库差异后,在准备版本的分支中一次性更新所有权威版本来源、生成文件
|
||||
和项目要求的 CHANGELOG。运行项目自己的版本更新工具时,先检查它会修改哪些文件,避免
|
||||
隐式发布、提交或上传。
|
||||
|
||||
提交前确认:
|
||||
|
||||
@@ -57,23 +68,38 @@ worktree 的 symlink;修改前确认规范化后的准确路径。不要用 gl
|
||||
- CHANGELOG 或发布说明描述的是本次实际变更。
|
||||
- 项目构建和测试读取到了新版本。
|
||||
|
||||
## 验证 merged commit
|
||||
## 生成更新说明
|
||||
|
||||
合并后以代码托管平台返回的 merged commit 为准。获取远端 base 后,确认该 commit 可从
|
||||
远端 base 到达,并直接读取该 commit 中的版本文件。不要用仍停留在 feature worktree
|
||||
中的文件证明已合并版本。
|
||||
以最近一个适用于当前发布线的稳定 tag 为起点,以 release commit 为终点,结合项目
|
||||
CHANGELOG、已合并 PR/MR 和实际 diff 生成更新说明。首次发布使用项目声明的发布基线;
|
||||
没有声明时核对完整可达历史,并在说明中明确这是首次发布。说明覆盖实际存在的新增、
|
||||
变更、修复、维护或文档变化;破坏性变化、迁移步骤和已知限制存在时必须单独标明。没有
|
||||
某一类别时可以省略该类别,不要生成空标题或模板占位。
|
||||
|
||||
每条说明都要能追溯到范围内的提交、PR/MR 或项目 CHANGELOG。提交消息和 PR/MR 文本只
|
||||
作为待核对素材,不能覆盖实际 diff,也不能把范围外变化写入本次说明。tag 创建前冻结
|
||||
完整多行说明;tag 已推送后不再修改。
|
||||
|
||||
## 验证 release commit
|
||||
|
||||
准备版本路径以代码托管平台返回的 merged commit 为准;直接发布路径以计划中锁定的远端
|
||||
commit 为准。获取远端 base 或允许的维护分支后,确认 release commit 可从对应远端引用
|
||||
到达,并直接读取该 commit 中的版本文件。不要用当前 checkout 或 feature worktree 中的
|
||||
文件证明 release commit 内容。
|
||||
|
||||
正式 tag 必须满足:
|
||||
|
||||
- tag 名按项目格式由目标版本唯一生成。
|
||||
- 远端没有同名 tag,或同名 tag 已经准确指向本次 commit。
|
||||
- annotated tag 显式指向 merged commit。
|
||||
- annotated tag 显式指向 release commit,并包含冻结后的完整更新说明;禁止 lightweight
|
||||
release tag。
|
||||
- 项目要求签名时,push 前已成功创建 signed tag,并在本地验证签名通过;不可用或失败
|
||||
时停止,不得改用 unsigned tag。
|
||||
- 推送后从远端重新读取,并将 annotated tag 解引用到 commit。
|
||||
- 推送后从远端重新读取 tag object,核对对象类型和完整说明,再将 annotated tag 解引用
|
||||
到 commit。
|
||||
|
||||
展开 tag pattern 后用 `git check-ref-format refs/tags/<tag>` 校验。把 tag 和 commit 作为
|
||||
独立 argv 传递,不拼接 shell 字符串。
|
||||
|
||||
tag 已在远端指向其他 commit 时停止。不要 force push、删除或移动已发布 tag;由维护者
|
||||
决定撤销发布或创建新的修订版本。
|
||||
tag 已在远端指向其他 commit、是 lightweight tag、缺少更新说明或说明不一致时停止。
|
||||
不要 force push、删除或移动已发布 tag;由维护者决定撤销发布或创建新的修订版本。
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
# 此文件描述发布策略,不授予任何远端写操作权限。
|
||||
schema: 1
|
||||
base_branch: main
|
||||
# 仅在发布必须修改仓库内容时使用;直接发布不会因此创建分支。
|
||||
# {version} 是规范版本,例如 1.6.0;前缀由 pattern 添加。
|
||||
branch_pattern: release/v{version}
|
||||
version:
|
||||
scheme: semver
|
||||
# 项目明确从 Git tag 派生版本时可以省略 sources。
|
||||
sources:
|
||||
- VERSION
|
||||
tag_pattern: v{version}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# orc
|
||||
|
||||
ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docker 意图映射成阶段,
|
||||
按静态配置选择 `low`、`mid` 或 `high` worker,再交给对应 Skill。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 一个请求同时包含写代码、发版本和构建产物。
|
||||
- 希望由 ORC 监督多个 Agent,并按阶段控制成本与推理能力。
|
||||
- 需要继续中断的多阶段工程流程并保留依赖关系。
|
||||
|
||||
只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- Orca 正在运行并启用了 orchestration。
|
||||
- 安装本次需要的 `$ack`、`$manage-release`、`$deb-publisher` 或
|
||||
`$publish-docker-image`。
|
||||
- ORC 直接使用 skill 内共享的 `config.yaml`,不需要在每个项目初始化配置。修改这份
|
||||
配置会影响所有项目;旧的项目级 `docs/orc/config.yaml` 不再参与解析。
|
||||
- `/usr/bin/python3`;ORC v2 配置必须保持为 JSON-compatible YAML。
|
||||
resolver 以 `-I -S` 隔离模式运行,不加载项目模块、用户 site-packages 或第三方解析器。
|
||||
- ORC v2 worker 使用 `workspace-write` sandbox,以便发送 Orca lifecycle 消息;
|
||||
只读任务会在阶段 prompt 中禁止文件修改。
|
||||
- release worker 是受信任的发布角色,直接调用当前 shell 中已认证的 `git` 与 Forge CLI
|
||||
(Gitea/Forgejo 使用 `tea`);ORC 不读取认证配置,也不注入 credential helper。
|
||||
- release 阶段要求 `origin` 只有一个且一致的 fetch/push URL。Codex 只为该阶段开启网络,
|
||||
并用 network proxy 把出站目标限制到 remote/Forge API host;GitHub 额外允许其固定 API
|
||||
与 release upload host,其它本地阶段默认继续禁网。
|
||||
|
||||
## Worker 档位
|
||||
|
||||
| 档位 | 典型任务 |
|
||||
|------|----------|
|
||||
| `low` | 输入明确的测试、构建、打包和上传 |
|
||||
| `mid` | 常规版本发布与范围清晰的工程任务 |
|
||||
| `high` | 需求理解、跨系统改动、异常恢复和高风险裁决 |
|
||||
|
||||
档位不是权限。三个档位仍受各自 profile 和下游 Skill 的授权边界约束。
|
||||
这些档位只选择下游 worker,不会切换当前 ORC Coordinator 已经使用的模型。
|
||||
结构化启动适配器支持 Codex 与 Cursor worker,并固定跟随当前宿主:从 Codex 调用就使用
|
||||
Codex,从 Cursor 调用就使用 Cursor。共享配置为两个 CLI 分别维护三档模型,不设置跨宿主
|
||||
默认值。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
$orc high 修复登录问题,验证通过后发布新版本。
|
||||
$orc code=high release=mid docker=low,完成修复、发版并推送镜像。
|
||||
$orc low,检查并准备下一个版本,不执行远端写操作。
|
||||
$orc mid 继续上次中断的 v1.4.0 发布流程。
|
||||
```
|
||||
|
||||
阶段级档位优先于全局档位;未指定时只读取 `stageDefaults` / `defaultLevel`。ORC 不根据
|
||||
任务复杂度动态升降档;worker 报告能力不足时,ORC 只转发请求。
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
1. 识别开发、源码发布、DEB 与 Docker 意图,并按固定表建立阶段依赖。
|
||||
2. 从当前 Agent 的系统身份锁定宿主 CLI,再解析每个阶段的 profile,把共享配置快照、
|
||||
可信 Agent CLI executable 和真实 Git
|
||||
worktree 绑定成 launch fingerprint,并展示计划和外部写入边界。
|
||||
3. 通过 Orca 分发给对应 Skill,转发完成消息、异常和决策门。
|
||||
4. 按下游 Skill 声明的证据字段汇总状态和恢复入口,不重新做领域判断。
|
||||
|
||||
ORC 不分析实现、不建议版本、不评估发布风险、不选择合并或构建方案;这些工作全部属于
|
||||
下游 Skill。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
最终结果会逐阶段列出所用 Skill、Agent 档位、源 revision、远端或产物状态,以及任何
|
||||
未完成项。只有所有必要阶段都通过各自验证时,ORC 才会报告整个流程完成。
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: orc
|
||||
description: >-
|
||||
作为薄路由器显式识别开发、源码版本发布、DEB 和 Docker 意图,把阶段分发给对应
|
||||
Skill,并按静态规则选择 low、mid、high 执行档位。仅在用户显式调用 $orc 或 /orc,
|
||||
要求跨阶段协调、指定 Agent 级别、监督多个 worker 或继续 ORC 编排时使用。
|
||||
---
|
||||
|
||||
# ORC 工程编排入口
|
||||
|
||||
当前会话担任薄路由器(Thin Coordinator):只识别阶段、宿主 CLI、静态档位、依赖和
|
||||
授权边界,然后派发 worker、转发决策门并汇总结构化状态。ORC 不做领域判断,不替代
|
||||
下游 Skill 执行、分析或复核其领域流程。
|
||||
|
||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<orc-skill-dir>`;解析真实项目根目录,
|
||||
优先使用 `git rev-parse --show-toplevel`。所有项目共享唯一配置
|
||||
`<orc-skill-dir>/config.yaml`,不得在项目中创建 `docs/orc/config.yaml` 或其它配置副本。
|
||||
|
||||
## 选择模式
|
||||
|
||||
- 用户要求初始化 ORC:说明 ORC 已改为共享配置、不需要项目初始化,然后执行“检查”。
|
||||
- 用户要求检查 ORC、CLI、档位或运行环境:执行“检查”。
|
||||
- 用户明确要求修改 ORC 的共享模型或档位默认值:执行“修改共享配置”。
|
||||
- 用户要求用 ORC 完成任务:执行“编排”。
|
||||
|
||||
配置或运行时无效时 fail closed,不退回裸命令或当前会话直接执行。
|
||||
|
||||
## 薄路由器边界
|
||||
|
||||
ORC 只执行以下机械步骤:
|
||||
|
||||
1. 把用户明确表达的意图映射到 `code`、`release`、`deb`、`docker`。
|
||||
2. 从系统身份映射宿主 CLI,并按显式覆盖或共享配置解析档位。
|
||||
3. 根据固定路由表建立任务依赖,生成满足契约的 worker prompt 并派发。
|
||||
4. 转发 `decision_gate` / `escalation`,按下游 Skill 的完成证据汇总状态。
|
||||
|
||||
不得阅读项目实现来形成技术判断,不得决定版本号、实现方案、测试策略、发布风险、
|
||||
合并方式或产物策略,不得代替 worker 执行命令。意图无法映射时报告范围外;缺少派发所需
|
||||
的关键输入时建立 decision gate。领域问题原样交给对应下游 Skill,不由 ORC 推理补全。
|
||||
|
||||
## 共享配置
|
||||
|
||||
`<orc-skill-dir>/config.yaml` 是 ORC profile 的 SSOT,对所有项目生效。配置使用
|
||||
JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含:
|
||||
|
||||
- `cliPolicy: current-host`:Codex 宿主只启动 Codex worker,Cursor 宿主只启动 Cursor worker。
|
||||
- `defaultLevel` 与 `stageDefaults`:只用于下游 worker 的 `low`、`mid`、`high` 默认选择,
|
||||
不表示 ORC Coordinator 自身的模型档位。
|
||||
- `worktreePolicy: registered-same-repository`:允许当前仓库中已注册且身份一致的 worktree。
|
||||
- `profiles.codex` 与 `profiles.cursor-agent`:两个 CLI 各自完整的三档 profile。
|
||||
|
||||
不要把项目路径、命令、argv、环境变量、secret、hook 或 shell 片段写入共享配置。
|
||||
旧项目若残留 `docs/orc/config.yaml`,resolver 会忽略它;没有用户明确清理授权时不要删除。
|
||||
|
||||
## 修改共享配置
|
||||
|
||||
只有用户明确要求修改 ORC 的全局档位或模型时才编辑
|
||||
`<orc-skill-dir>/config.yaml`。修改前说明它会影响所有项目;模型 ID 必须来自用户输入、
|
||||
目标 CLI 的模型列表或其它可信配置,不能从任务文本猜测。修改后运行:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py validate
|
||||
```
|
||||
|
||||
报告两个 CLI 的三个档位、reasoning、权限策略、阶段默认值和宿主 CLI 策略。不要创建
|
||||
项目配置。
|
||||
|
||||
## 检查
|
||||
|
||||
1. 运行共享配置校验,确认 `codex` 和 `cursor-agent` 都完整配置 `low`、`mid`、`high`。
|
||||
2. 检查 `orca status --json`,并确认 orchestration 命令可用。
|
||||
3. 从当前 Agent 的系统身份确定宿主 CLI:Codex 映射为 `codex`,Cursor 映射为
|
||||
`cursor-agent`。身份不明确时停止,不从用户任务文本、默认值或已安装 executable 猜测。
|
||||
确认宿主 CLI 可用;在 Cursor 中还要用 `cursor-agent --list-models`
|
||||
核对精确模型 ID 对当前账号可见。
|
||||
4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、
|
||||
`publish-docker-image`。只检查实际会用到的项。
|
||||
5. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标是
|
||||
当前 Git 仓库已注册的 worktree 且身份稳定后才可创建终端。不得只做文本比较或跳过
|
||||
机器校验。
|
||||
6. 任何 profile、CLI、Skill、运行时或 worktree 不可用时 fail closed;不得切换其它 CLI、
|
||||
相邻档位、复用身份不明的终端或手写替代流程。
|
||||
|
||||
## 编排
|
||||
|
||||
1. 读取 [routing.md](references/routing.md),把请求拆成 `code`、`release`、`deb`、
|
||||
`docker` 阶段。没有匹配下游 Skill 的工作留在范围外并明确报告。
|
||||
2. 原样提取用户明确给出的授权最远动作、目标版本、产物目标、源 commit/tag 与停止点;
|
||||
未给出的领域参数保持未指定并交给下游 Skill。ORC 的调用本身不扩大 push、合并、
|
||||
打 tag、上传或部署权限。
|
||||
3. 从系统身份锁定当前宿主 CLI。运行在 Codex 时传 `--host-cli codex`;运行在 Cursor 时传
|
||||
`--host-cli cursor-agent`。宿主 CLI 不是任务选项,即使用户文本点名另一个 CLI 也不得
|
||||
跨宿主启动;身份不明确时 fail closed。
|
||||
4. 解析档位:阶段级指定 > 全局指定 > `stageDefaults` > `defaultLevel`。只接受
|
||||
`low`、`mid`、`high`;用户显式指定后不得静默升降级。ORC 不根据任务复杂度动态判断
|
||||
档位;worker 报告能力不足时,只转发 escalation 或 decision gate。
|
||||
5. 对每个阶段运行 profile resolver。宿主用必填的 `--host-cli`,全局档位用
|
||||
`--global-level`,阶段
|
||||
档位用 `--stage-level`;项目根与目标 worktree 必须使用规范绝对路径。Codex 默认复用
|
||||
`codex-login`,Cursor 默认复用 `cursor-login`;只有明确使用环境凭据时才分别选择
|
||||
`openai`、`azure-openai` 或 `cursor-api-key`。远端认证默认 `none`。release worker 作为
|
||||
受信任发布角色,直接调用当前 shell 中已认证的 `git` 与 Forge CLI;Gitea/Forgejo 由
|
||||
`$manage-release` 在目标 worktree 中运行 `tea`,resolver 不读取 tea 配置、不注入
|
||||
credential helper,也不把 tea token 转成环境变量。只有明确改用环境 token 或 SSH
|
||||
agent 时,才选择一个精确的 `github-token`、`gitlab-token`、`gitea-token`、
|
||||
`forgejo-token`、`ssh-agent` 或 `deb-token`,不得同时交给 worker 多组环境凭据。resolver
|
||||
不存在静默 fallback:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py resolve \
|
||||
--stage <stage> \
|
||||
--project-root <absolute-project-root> \
|
||||
--worktree <absolute-target-worktree> \
|
||||
--host-cli <codex|cursor-agent> \
|
||||
[--global-level <low|mid|high>] [--stage-level <low|mid|high>] \
|
||||
[--model-auth <codex-login|openai|azure-openai|cursor-login|cursor-api-key>] \
|
||||
[--remote-auth <exact-provider-or-transport>]
|
||||
```
|
||||
|
||||
6. 核对 resolver 返回的 `launchFingerprint`、绝对 executable、worktree identity、CLI 与
|
||||
档位选择来源,再读取 [orca-adapter.md](references/orca-adapter.md),把阶段组织为 Orca
|
||||
task DAG。worker prompt 必须显式写出对应 `$skill`、阶段范围、输入 revision、用户授权
|
||||
边界、验收证据和依赖结果;下游 Skill 无需知道 ORC。
|
||||
7. 监督 `worker_done`、`escalation` 与 `decision_gate`。`worker_done` 只代表该 worker
|
||||
回报完成;Coordinator 只核对下游 Skill 声明的证据字段和 DAG 后置条件,不重新进行
|
||||
领域审查。
|
||||
8. 逐阶段汇报所选 CLI、档位、执行 Skill、结果、外部状态和未完成项。任一阶段失败时
|
||||
保留已成功阶段的准确状态,说明安全恢复入口,不把部分成功概括成全部完成。
|
||||
|
||||
## 固定路由边界
|
||||
|
||||
- 功能、缺陷、重构与验证闭环交给 `$ack`。
|
||||
- 发布版本、release 分支/PR/MR、合并、tag 与 Forge Release 交给
|
||||
`$manage-release`。
|
||||
- DEB 构建或上传交给 `$deb-publisher`。
|
||||
- Docker/OCI 镜像构建或上传交给 `$publish-docker-image`。
|
||||
- 普通非发布 PR/MR 不伪装成版本发布;只有 ACK 已验证交付或明确的 release 流程才
|
||||
进入对应下游能力。
|
||||
|
||||
## 依赖与安全边界
|
||||
|
||||
- 依赖始终单向:`orc -> 下游 Skill`。不得要求 ACK 或其它下游 Skill 引用 ORC、读取
|
||||
ORC 配置或改变自身触发规则。
|
||||
- ORC 档位只选择阶段 worker。进入 `$ack` 后,ACK 自己的 Coordinator、Developer、
|
||||
Test 角色和 `standard/strong` 模型路由仍完全由 ACK 管理。
|
||||
- ORC 的 `code` 阶段必须锁定停止点:纯开发停在 ACK `verified`;用户明确要求普通
|
||||
PR/MR 时最多到 ACK `review_ready`。不得让 ACK 在同一阶段继续执行版本发布、DEB、
|
||||
Docker 或部署;这些动作由 ORC 的独立阶段负责。
|
||||
- ORC v2 的 `permissionMode` 固定为 `workspace-write`,因为受监督 worker 需要写入
|
||||
Orca 运行时目录才能发送 lifecycle 消息。只读任务仍由 prompt 限制不得改文件。
|
||||
- release 阶段必须绑定 `origin` 唯一且规范化后完全一致的 fetch/push URL,并把 remote
|
||||
identity 写入 fingerprint;多个 URL、独立 `pushurl` 或不支持的 remote 直接停止。
|
||||
- Codex release worker 固定使用 `on-request` 与 `auto_review`,开启
|
||||
`sandbox_workspace_write.network_access=true`,并用 network proxy 只允许绑定的 remote
|
||||
与确定性的 Forge API host。GitHub 固定追加 `api.github.com` 和 `uploads.github.com`;
|
||||
Gitea、Forgejo 与 GitLab 默认只使用 remote host。其它本地阶段默认禁网;不得改用
|
||||
`danger-full-access`。Cursor 继续使用自身的 `--auto-review --sandbox enabled`。
|
||||
- 安全启动适配器只支持 `codex` 和 `cursor-agent`。Codex 使用结构化 sandbox 与 approval
|
||||
参数;Cursor 使用 `--auto-review --sandbox enabled --workspace <path>`。不接受
|
||||
full-access、bypass、`--force`、`--yolo` 或关闭 sandbox。
|
||||
- `cliPolicy` 固定为 `current-host`,resolver 不提供默认 CLI;漏传宿主身份会直接失败,
|
||||
不得从共享配置回退到 Codex 或 Cursor。
|
||||
- Cursor 的 reasoning 档位编码在精确模型 ID 中,因此其 `reasoningEffort` 必须为 null;
|
||||
Codex 则显式传递 `model_reasoning_effort`。
|
||||
- resolver 只读取 skill 内有大小上限的普通共享配置文件,拒绝 symlink/special file;
|
||||
启动计划绑定配置快照、root-owned 隔离 Python、可信绝对 Agent/Orca executable、Git
|
||||
worktree identity、release remote identity、精确环境认证选择和固定 argv。实际启动会
|
||||
重新校验 fingerprint;release 阶段允许受信任的 `$manage-release` 直接使用当前 shell
|
||||
已有的 Git/Forge 登录,resolver 不检查或复制认证文件。显式 token/SSH 认证仍只注入所选
|
||||
环境变量。不得改写 resolver 返回的 worker argv,也不得把终端创建 argv 的绝对 Orca
|
||||
路径换成项目 `PATH` 解析。
|
||||
- 不把模型档位当作权限。`high` 不自动获得更多文件、凭据、网络或远端写权限。
|
||||
- 不执行 `orca orchestration reset`,除非用户明确要求放弃全部相关运行时状态。
|
||||
|
||||
## 完成标准
|
||||
|
||||
每个计划阶段都有明确下游 Skill、Agent CLI、档位、输入 revision、授权边界和可核对结果;
|
||||
DAG 中所有必要阶段完成,或失败阶段具有准确状态与恢复入口。ACK 和其它下游 Skill
|
||||
保持独立且不存在对 ORC 的反向引用。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "ORC"
|
||||
short_description: "用 Codex 或 Cursor 分档编排开发、发布与产物任务"
|
||||
default_prompt: "Use $orc to coordinate this engineering task on the current host CLI with a low, mid, or high worker level."
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 2,
|
||||
"cliPolicy": "current-host",
|
||||
"defaultLevel": "mid",
|
||||
"stageDefaults": {
|
||||
"code": "high",
|
||||
"release": "mid",
|
||||
"deb": "low",
|
||||
"docker": "low"
|
||||
},
|
||||
"worktreePolicy": "registered-same-repository",
|
||||
"profiles": {
|
||||
"codex": {
|
||||
"low": {
|
||||
"model": "gpt-5.6-luna",
|
||||
"reasoningEffort": "low",
|
||||
"permissionMode": "workspace-write",
|
||||
"approvalPolicy": "never"
|
||||
},
|
||||
"mid": {
|
||||
"model": "gpt-5.6-terra",
|
||||
"reasoningEffort": "medium",
|
||||
"permissionMode": "workspace-write",
|
||||
"approvalPolicy": "never"
|
||||
},
|
||||
"high": {
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoningEffort": "high",
|
||||
"permissionMode": "workspace-write",
|
||||
"approvalPolicy": "never"
|
||||
}
|
||||
},
|
||||
"cursor-agent": {
|
||||
"low": {
|
||||
"model": "auto",
|
||||
"reasoningEffort": null,
|
||||
"permissionMode": "workspace-write",
|
||||
"approvalPolicy": "auto-review"
|
||||
},
|
||||
"mid": {
|
||||
"model": "gpt-5.6-terra-medium",
|
||||
"reasoningEffort": null,
|
||||
"permissionMode": "workspace-write",
|
||||
"approvalPolicy": "auto-review"
|
||||
},
|
||||
"high": {
|
||||
"model": "gpt-5.6-sol-high",
|
||||
"reasoningEffort": null,
|
||||
"permissionMode": "workspace-write",
|
||||
"approvalPolicy": "auto-review"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# ORC 的 Orca 适配
|
||||
|
||||
Orca 保存运行时任务、依赖、dispatch 和消息;ORC 保存稳定路由规则。不要在项目中
|
||||
复制一份 Orca 运行时任务板。
|
||||
|
||||
## 运行前
|
||||
|
||||
```bash
|
||||
orca status --json
|
||||
orca orchestration task-list --json
|
||||
orca orchestration inbox --limit 20 --json
|
||||
```
|
||||
|
||||
确认 runtime 可达、orchestration 可用,并识别是否存在与当前请求相同的活跃任务。
|
||||
恢复流程时复用身份匹配的 task;身份不明时不要猜测或清空全局状态。
|
||||
|
||||
## 解析安全启动计划
|
||||
|
||||
对每个阶段调用 `resolve_profile.py resolve`,同时传入规范绝对 `--project-root`、
|
||||
`--worktree` 和从当前 Agent 系统身份确定的必填 `--host-cli`,以及本阶段精确的
|
||||
`--model-auth`、`--remote-auth`。
|
||||
resolver 始终读取 skill 内共享的 `config.yaml`,不接受项目配置路径。JSON 结果包含
|
||||
选择来源、结构化 profile、认证选择、配置与 worktree identity、可信 Python/Agent
|
||||
CLI/Orca executable、
|
||||
`launchFingerprint`、固定 worker/launcher argv,以及完整的 `terminalCreateArgv` 和
|
||||
`terminalCreateShellCommand`。
|
||||
|
||||
- 优先把返回的 `terminalCreateArgv` 原样交给 argv-capable 进程工具;若工具只接受 shell
|
||||
字符串,则执行完整的 `terminalCreateShellCommand`。不得再插值、包引号或追加参数。
|
||||
- `terminalCreateArgv` 的首项是 resolver 选定的绝对 Orca executable,不替换成裸
|
||||
`orca` 或重新通过项目 `PATH` 查找。
|
||||
- 不从 YAML 自行拼接命令,不追加 argv、环境变量、hook 或权限参数。
|
||||
- `remote-auth` 只表示显式注入的单一环境 token 或 SSH agent;默认 `none`,不为方便同时
|
||||
暴露多组环境凭据。release worker 直接使用当前 shell 已有的 Git/Forge CLI 登录,
|
||||
resolver 不读取 tea/gh/glab 配置,也不注入 credential helper。
|
||||
- release 计划要求 `origin` 恰好一个 fetch URL 和一个 push URL,二者规范化后完全一致;
|
||||
remote identity 会进入 fingerprint,并在实际 launch 时重新解析。独立 `pushurl`、多个
|
||||
push URL、local/file remote 或带内嵌 HTTPS 凭据的 URL 都 fail closed。
|
||||
- Codex release 计划固定使用 `on-request` + `auto_review`,开启 workspace-write 网络并启用
|
||||
network proxy,只允许绑定的 remote 与确定性 Forge API host。GitHub 固定追加
|
||||
`api.github.com` 和 `uploads.github.com`;其它本地阶段保持默认禁网。不得替换成
|
||||
`danger-full-access`。Cursor 继续使用自身的 auto-review sandbox。
|
||||
- resolver 失败时停止;不改用默认模型、邻近档位或已有未知终端。
|
||||
- 不从任务文本、可执行文件存在性或共享配置猜宿主;Codex 传 `--host-cli codex`,Cursor
|
||||
传 `--host-cli cursor-agent`。宿主身份不明确时停止。
|
||||
- 不重复手写 worktree 检查;resolver 已拒绝 root、不存在、非规范、含 symlink、未注册
|
||||
或跨仓库的目标,并把身份写入 fingerprint。
|
||||
- ORC v2 profile 必须使用 `workspace-write`。`read-only` sandbox 可能阻止 Orca CLI
|
||||
写入自身运行时或挂载目录,导致 worker 无法发送 `worker_done`;只读工作通过 task
|
||||
spec 限制,而不是换成无法完成 lifecycle 的 sandbox。
|
||||
- Codex 与 Cursor 使用不同的固定 argv 适配器。Cursor 只允许 `--auto-review`、启用的
|
||||
sandbox 和精确 workspace;不得把 Codex 参数套给 Cursor,也不得给 Cursor 追加
|
||||
`--force` 或 `--yolo`。
|
||||
|
||||
## 创建与派发
|
||||
|
||||
每个需要 fresh worker 的阶段依次执行:
|
||||
|
||||
1. 使用解析结果创建目标 worktree 内的终端。下面整行代表 resolver 返回的一个完整值,
|
||||
不是待替换的参数模板:
|
||||
|
||||
```bash
|
||||
<resolver-terminalCreateShellCommand>
|
||||
```
|
||||
|
||||
2. 使用返回的具体 handle 等待 Agent TUI 可接收输入:
|
||||
|
||||
```bash
|
||||
orca terminal wait --terminal <handle> --for tui-idle \
|
||||
--timeout-ms 60000 --json
|
||||
```
|
||||
|
||||
3. 先为前置阶段创建 task,再用其 ID 构造依赖数组创建后置 task。task spec 使用
|
||||
`routing.md` 的 worker prompt 契约。
|
||||
4. 对已就绪 task 执行:
|
||||
|
||||
```bash
|
||||
orca orchestration dispatch --task <task-id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
不要把同一 task 同时派给多个写 worker。并行阶段必须使用不会竞争同一工作树写入的
|
||||
独立 worker;否则串行执行。`terminal create` 超时或连接中断时状态不确定:先用
|
||||
`terminalTitle`、worktree 和 fingerprint 核对 `orca terminal list --json`,没有完成
|
||||
reconciliation 前不得重试创建。
|
||||
|
||||
## 监督循环
|
||||
|
||||
```bash
|
||||
orca orchestration check --wait \
|
||||
--types worker_done,escalation,decision_gate \
|
||||
--timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
- timeout 或空结果是检查点,不等于失败;任务仍活跃时继续滚动等待。
|
||||
- 收到 `decision_gate` 时只回答当前问题,不扩大用户授权。
|
||||
- 收到 `escalation` 时保留已完成状态,判断是改档、补输入还是停止。
|
||||
- 收到 `worker_done` 后核对 task/dispatch ID、下游完成证据和外部状态,再把依赖 task
|
||||
标记完成并派发新就绪阶段。
|
||||
- 三次连续同因失败后停止重派,报告恢复入口;不要用新终端掩盖同一阻塞。
|
||||
|
||||
## ACK 特例
|
||||
|
||||
`code` worker 是运行 `$ack` 的阶段 Coordinator。ORC 的 `low/mid/high` 只决定这个
|
||||
worker 的启动 profile;ACK 启动 Developer/Test 时继续读取自己的项目配置与
|
||||
`standard/strong` 规则。ORC 不读取、改写或覆盖 ACK 的内部 worker profiles。
|
||||
|
||||
## 收尾
|
||||
|
||||
不要自动关闭仍承担恢复入口的终端,不自动删除 worktree 或清空 runtime-global task。
|
||||
只有用户明确要求清理时,才按 Orca 和下游 Skill 的安全规则处理。
|
||||
@@ -0,0 +1,78 @@
|
||||
# ORC 阶段路由
|
||||
|
||||
ORC 是薄路由器,只负责意图映射、依赖、静态档位和结构化状态汇总。它不判断版本号、
|
||||
实现方案、发布风险或产物策略;领域步骤、授权检查和完成标准由下游 Skill 自己决定。
|
||||
|
||||
## 路由表
|
||||
|
||||
| 阶段 | 下游 Skill | 包含 | 不包含 |
|
||||
|------|------------|------|--------|
|
||||
| `code` | `$ack` | 功能、缺陷、重构、测试、三角色验证闭环,以及用户明确要求的普通非发布 PR/MR | 版本发布、单独上传产物 |
|
||||
| `release` | `$manage-release` | 版本号、release worktree/分支、release PR/MR、合并、tag、Forge Release、恢复发布 | 普通非发布 PR/MR、构建或上传 DEB/Docker |
|
||||
| `deb` | `$deb-publisher` | DEB 构建、校验、上传与仓库可见性 | 源码 tag、Docker 镜像 |
|
||||
| `docker` | `$publish-docker-image` | Docker/OCI 构建、push、digest 与平台验证 | 源码版本生命周期、DEB |
|
||||
|
||||
没有匹配项时不要临时扩写某个 Skill 的职责,也不要让 ORC 自己模仿领域流程。报告缺少
|
||||
的能力,由用户决定直接执行、安装新 Skill 或另行设计。
|
||||
|
||||
路由只依据用户明确表达的目标。缺少目标版本、revision、产物目标或授权停止点时,保留
|
||||
为未指定并交给下游 Skill;只有缺少创建 task 所必需的项目或阶段身份时才建立
|
||||
decision gate。不得为了填满 worker prompt 而分析代码、推断版本或设计执行方案。
|
||||
|
||||
## 拆分规则
|
||||
|
||||
1. 先从用户请求提取最终结果,再拆出真正需要的阶段;不要因为安装了某个 Skill 就
|
||||
自动增加发布或上传。
|
||||
2. 为每个阶段锁定输入:项目、worktree、源 commit/tag、版本、目标和用户授权的最远
|
||||
写操作。
|
||||
3. 同一领域的连续动作保留在一个下游任务中。例如版本号、release PR、合并和 tag
|
||||
属于一个 `manage-release` 生命周期,不拆成多个互相争抢状态的 worker。
|
||||
4. 只有输入 revision 完全相同且互不修改同一工作树时,才并行执行 DEB 与 Docker。
|
||||
5. 普通代码改动进入 ACK。若项目尚未初始化 ACK,`code` 阶段停在前置条件,不由 ORC
|
||||
静默初始化。
|
||||
6. `code` 阶段默认停在 ACK `verified`;用户明确要求普通 PR/MR 时最多到
|
||||
`review_ready`。worker prompt 必须禁止继续执行版本发布、DEB、Docker 或部署。
|
||||
|
||||
## 常见 DAG
|
||||
|
||||
完整交付:
|
||||
|
||||
```text
|
||||
code ($ack)
|
||||
-> release ($manage-release)
|
||||
-> deb ($deb-publisher)
|
||||
-> docker ($publish-docker-image)
|
||||
```
|
||||
|
||||
只从当前 commit 构建产物:
|
||||
|
||||
```text
|
||||
deb ($deb-publisher) || docker ($publish-docker-image)
|
||||
```
|
||||
|
||||
仅发布源码版本:
|
||||
|
||||
```text
|
||||
release ($manage-release)
|
||||
```
|
||||
|
||||
依赖不是固定模板,但 ORC v2 不拆分一个 `manage-release` 生命周期。若项目要求在打开
|
||||
release PR 与合并之间插入 DEB/Docker gate,当前 task 粒度无法安全表达该中间里程碑;
|
||||
在打开 PR 前建立 decision gate 并报告该流程暂不支持,不得用循环依赖或两个 release
|
||||
worker 临时拼接。
|
||||
|
||||
## Worker prompt 契约
|
||||
|
||||
每个 worker prompt 至少包含:
|
||||
|
||||
- 第一条指令显式调用唯一的下游 Skill,例如 `Use $manage-release ...`。
|
||||
- 阶段目标与明确的非目标。
|
||||
- 项目/worktree、输入 commit/tag 和前置阶段的可核对结果。
|
||||
- 用户已经授予的最远动作;未授权动作明确禁止。
|
||||
- 要求遵循项目 Agent 指令和下游 Skill 自身的停止条件。
|
||||
- 完成证据,以及通过 live dispatch preamble 回报 `worker_done` 的要求。
|
||||
- `code` prompt 还必须写明 ACK 停止点是 `verified` 或普通 PR 的 `review_ready`,并禁止
|
||||
ACK 路由版本、DEB、Docker 或部署动作。
|
||||
|
||||
不要把 ORC 的 profile、路由器内部规则或其它下游 Skill 注入 worker。worker 只需要
|
||||
当前阶段、对应 Skill 和必要依赖结果。
|
||||
Executable
+1074
File diff suppressed because it is too large
Load Diff
@@ -185,6 +185,33 @@ class AckDeliveryValidationTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||
|
||||
def test_default_validation_profile_requires_deploy_and_health_check(self) -> None:
|
||||
contract = valid_contract()
|
||||
contract["defaultProfile"] = "local-validation"
|
||||
contract["profiles"]["local-validation"] = {
|
||||
"stopAt": "validation_ready",
|
||||
"steps": [
|
||||
{"id": "build-local", "action": "build", "artifact": "service-deb"},
|
||||
{
|
||||
"id": "deploy-local",
|
||||
"action": "deploy",
|
||||
"artifact": "service-deb",
|
||||
"environment": "test-server",
|
||||
},
|
||||
{
|
||||
"id": "health-local",
|
||||
"action": "health-check",
|
||||
"environment": "test-server",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||
|
||||
contract["profiles"]["local-validation"]["steps"].pop()
|
||||
errors = validate_delivery.validate_builtin(contract)
|
||||
self.assertTrue(any("必须全部完成 health-check" in item for item in errors))
|
||||
|
||||
def test_publish_and_health_check_require_prior_steps(self) -> None:
|
||||
contract = valid_contract()
|
||||
steps = contract["profiles"]["review"]["steps"]
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import contextlib
|
||||
import io
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
READER = ROOT / "skills/ack/scripts/feishu_bug_intake.py"
|
||||
sys.path.insert(0, str(READER.parent))
|
||||
import feishu_bug_intake # noqa: E402
|
||||
|
||||
|
||||
class FeishuIntakeBlackBox(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="feishu-e2e-")
|
||||
self.d = Path(self.tmp.name)
|
||||
self.bin = self.d / "bin"
|
||||
self.bin.mkdir()
|
||||
self.fixture = self.d / "submitted-bug.json"
|
||||
self.fixture.write_text(json.dumps({
|
||||
"recordId": "recBug1",
|
||||
"title": "Crash on save", "actual": "button crashes",
|
||||
"expected": "save succeeds", "steps": ["open app", "click Save"],
|
||||
"acceptance": "regression covered", "priority": "P1",
|
||||
"attachments": [{"file_token": "fileTok", "name": "screen.png", "type": "image/png", "size": 4}],
|
||||
"updatedAt": "2026-08-01T12:00:00Z",
|
||||
}), encoding="utf-8")
|
||||
self.mode_file = self.d / "mode.txt"
|
||||
self.mode_file.write_text("", encoding="utf-8")
|
||||
self.log = self.d / "argv.jsonl"
|
||||
self.fake = self.bin / "lark-cli"
|
||||
self.fake.write_text(
|
||||
"#!" + sys.executable + "\n"
|
||||
"import json, os, pathlib, sys\n"
|
||||
f"fixture=json.loads(pathlib.Path({str(self.fixture)!r}).read_text())\n"
|
||||
f"mode=pathlib.Path({str(self.mode_file)!r}).read_text().strip()\n"
|
||||
f"log=pathlib.Path({str(self.log)!r}); a=sys.argv[1:]\n"
|
||||
"with log.open('a') as f: f.write(json.dumps(a)+'\\n')\n"
|
||||
"p=a[a.index('--profile')+1] if '--profile' in a else None\n"
|
||||
"if a[:2]==['profile','list']:\n"
|
||||
" if '--format' in a: raise SystemExit(8)\n"
|
||||
" print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}])); raise SystemExit\n"
|
||||
"if p != 'tenant-b': print('wrong tenant',file=sys.stderr); raise SystemExit(9)\n"
|
||||
"if mode=='malformed': print('{bad'); raise SystemExit\n"
|
||||
"if a[:2]==['base','+record-list']:\n"
|
||||
" if mode=='okfalse': print(json.dumps({'ok':False})); raise SystemExit\n"
|
||||
" if mode=='code': print(json.dumps({'code':7})); raise SystemExit\n"
|
||||
" if mode=='ambiguous': print(json.dumps({'data':{}})); raise SystemExit\n"
|
||||
" if mode=='noprog': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[],'data':[],'has_more':True}})); raise SystemExit\n"
|
||||
" if mode=='max': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['x']*8],'has_more':True}})); raise SystemExit\n"
|
||||
" if mode=='matrix': print(json.dumps({'code':0,'data':{'fields':['fTitle'],'record_id_list':['recBug1'],'records':[]}})); raise SystemExit\n"
|
||||
" if mode=='cell': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[[{'unexpected':'value'},'actual','expected','steps','accept','P1',[],f['updatedAt']]]}})); raise SystemExit\n"
|
||||
" if mode=='empty': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title',None,'expected','steps','accept','P1',[],fixture['updatedAt']]]}})); raise SystemExit\n"
|
||||
" if mode=='enrichment': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title','actual','expected',None,None,None,[],fixture['updatedAt']]]}})); raise SystemExit\n"
|
||||
" if mode=='blank': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBlank'],'data':[[None,None,None,None,None,None,[],fixture['updatedAt']]]}})); raise SystemExit\n"
|
||||
" f=fixture; print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[f['recordId']],'data':[[f['title'],f['actual'],f['expected'],f['steps'],f['acceptance'],f['priority'],f['attachments'],f['updatedAt']]]}})); raise SystemExit\n"
|
||||
"if a[:2]==['base','+record-download-attachment']:\n"
|
||||
" out=pathlib.Path(a[a.index('--output')+1]); out.mkdir(parents=True,exist_ok=True)\n"
|
||||
" if mode=='escape': (out/'escape').symlink_to('/tmp'); raise SystemExit\n"
|
||||
" (out/'screen.png').write_bytes(b'fake'); print(json.dumps({'ok':True})); raise SystemExit\n"
|
||||
"raise SystemExit(2)\n", encoding="utf-8")
|
||||
self.fake.chmod(0o755)
|
||||
self.env = {**os.environ, "PATH": f"{self.bin}{os.pathsep}{os.environ.get('PATH','')}"}
|
||||
self.env["FAKE_FIXTURE"] = str(self.fixture)
|
||||
self.board = self.d / "tasks.yaml"
|
||||
self.board.write_text("""version: 1
|
||||
project:
|
||||
name: isolated-fake
|
||||
bugIntake:
|
||||
provider: feishu-base
|
||||
profile: tenant-b
|
||||
baseToken: base-secret
|
||||
tableId: tbl-bugs
|
||||
viewId: view-ready
|
||||
fields:
|
||||
title: fTitle
|
||||
actual: fActual
|
||||
expected: fExpected
|
||||
stepsToReproduce: fSteps
|
||||
acceptance: fAcceptance
|
||||
priority: fPriority
|
||||
attachments: fAttachments
|
||||
updatedAt: fUpdated
|
||||
tasks: []
|
||||
""", encoding="utf-8")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmp.cleanup()
|
||||
|
||||
def invoke(self, command: str, *, mode: str | None = None, output: Path | None = None, resolver=None):
|
||||
env = dict(self.env)
|
||||
self.mode_file.write_text(mode or "", encoding="utf-8")
|
||||
args = [command, str(self.board)]
|
||||
if output:
|
||||
args += ["--output-dir", str(output)]
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=self.fake) if resolver is None else mock.patch.object(feishu_bug_intake, "resolve_lark_cli", side_effect=resolver), \
|
||||
mock.patch.dict(os.environ, env, clear=True), \
|
||||
contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
try:
|
||||
code = feishu_bug_intake.main(args)
|
||||
except SystemExit as exc:
|
||||
code = int(exc.code or 0)
|
||||
return subprocess.CompletedProcess(args, code, stdout.getvalue(), stderr.getvalue())
|
||||
|
||||
def test_isolated_tenant_check_fetch_and_triage_contract(self) -> None:
|
||||
self.assertEqual(self.invoke("check").returncode, 0)
|
||||
downloads = self.d / "downloads"
|
||||
result = self.invoke("fetch", output=downloads)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
record = payload["records"][0]
|
||||
self.assertEqual(record["recordId"], "recBug1")
|
||||
self.assertEqual(record["priority"], "P1")
|
||||
self.assertEqual(record["updatedAt"], "2026-08-01T12:00:00Z")
|
||||
self.assertIn("Crash on save", record["title"])
|
||||
self.assertTrue(Path(record["attachments"][0]["localPath"]).is_relative_to(downloads))
|
||||
self.assertRegex(record["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
for secret in ("tenant-b", "base-secret", "tbl-bugs", "recBug1", "fileTok"):
|
||||
self.assertNotIn(secret, record["sourceRef"])
|
||||
for secret in ("base-secret", "fileTok", "app-secret-sentinel"):
|
||||
self.assertNotIn(secret, result.stdout)
|
||||
second = self.invoke("fetch", output=self.d / "repeat")
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
second_record = json.loads(second.stdout)["records"][0]
|
||||
self.assertRegex(second_record["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
self.assertEqual(record["sourceRef"], second_record["sourceRef"])
|
||||
for secret in ("tenant-b", "base-secret", "tbl-bugs", "recBug1", "fileTok"):
|
||||
self.assertNotIn(secret, second_record["sourceRef"])
|
||||
for secret in ("base-secret", "fileTok", "app-secret-sentinel"):
|
||||
self.assertNotIn(secret, second.stdout)
|
||||
calls = [json.loads(x) for x in self.log.read_text().splitlines()]
|
||||
for call in calls:
|
||||
if call[:1] == ["base"]:
|
||||
self.assertEqual(call[call.index("--profile") + 1], "tenant-b")
|
||||
self.assertNotIn("tenant-a", call)
|
||||
if call[:2] == ["base", "+record-download-attachment"]:
|
||||
self.assertFalse(Path(call[call.index("--output") + 1]).is_absolute())
|
||||
self.assertEqual(sum(1 for c in calls if c[:2] == ["base", "+record-list"]), 2)
|
||||
list_call = next(c for c in reversed(calls) if c[:2] == ["base", "+record-list"])
|
||||
self.assertEqual(list_call[list_call.index("--view-id") + 1], "view-ready")
|
||||
self.assertEqual(list_call[:2], ["base", "+record-list"])
|
||||
self.assertEqual(list_call.count("--field-id"), 8)
|
||||
|
||||
def test_fail_closed_wrong_profile_and_unsafe_response(self) -> None:
|
||||
bad = self.board.read_text().replace("profile: tenant-b", "profile: tenant-a")
|
||||
self.board.write_text(bad)
|
||||
result = self.invoke("fetch")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.board.write_text(bad.replace("profile: tenant-a", "profile: tenant-b"))
|
||||
result = self.invoke("fetch", mode="malformed")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
result = self.invoke("fetch", mode="matrix", output=self.d / "matrix-output")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
result = self.invoke("fetch", mode="cell", output=self.d / "cell-output")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
result = self.invoke("fetch", mode="empty", output=self.d / "empty-output")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
result = self.invoke("fetch", mode="escape", output=self.d / "downloads")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def test_missing_coordinator_fields_are_planned_for_enrichment(self) -> None:
|
||||
fetched = self.invoke("fetch", mode="enrichment")
|
||||
self.assertEqual(fetched.returncode, 0, fetched.stderr)
|
||||
payload = json.loads(fetched.stdout)
|
||||
self.assertEqual(payload["warnings"], [])
|
||||
self.assertEqual(payload["records"][0]["enrichmentRequired"], [
|
||||
"steps", "acceptance", "priority",
|
||||
])
|
||||
|
||||
planned = self.invoke("plan", mode="enrichment")
|
||||
self.assertEqual(planned.returncode, 0, planned.stderr)
|
||||
action = json.loads(planned.stdout)["actions"][0]
|
||||
self.assertEqual(action["action"], "create")
|
||||
self.assertEqual(action["enrichmentRequired"], [
|
||||
"steps", "acceptance", "priority",
|
||||
])
|
||||
|
||||
def test_fully_blank_rows_are_skipped_with_a_batch_warning(self) -> None:
|
||||
result = self.invoke("fetch", mode="blank")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["records"], [])
|
||||
self.assertEqual(payload["warnings"], [{
|
||||
"recordId": "recBlank",
|
||||
"code": "blank_record_skipped",
|
||||
}])
|
||||
|
||||
def test_resolver_trust_and_no_executable_override_surface(self) -> None:
|
||||
trusted, hostile = self.d / "trusted", self.d / "hostile"
|
||||
trusted.mkdir(); hostile.mkdir()
|
||||
safe = trusted / "lark-cli"
|
||||
safe.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8"); safe.chmod(0o755)
|
||||
(hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
|
||||
(hostile / "lark-cli").chmod(0o755)
|
||||
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]), mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False):
|
||||
self.assertEqual(feishu_bug_intake.resolve_lark_cli(), safe)
|
||||
safe.chmod(0o775)
|
||||
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.resolve_lark_cli()
|
||||
help_result = self.invoke("--help")
|
||||
self.assertEqual(help_result.returncode, 0)
|
||||
self.assertNotIn("--lark-cli", help_result.stdout + help_result.stderr)
|
||||
self.assertNotIn("--executable", help_result.stdout + help_result.stderr)
|
||||
self.assertNotIn("executable_override", READER.read_text(encoding="utf-8"))
|
||||
|
||||
def test_fail_closed_envelopes_pagination_and_missing_trusted_cli(self) -> None:
|
||||
for mode in ("okfalse", "code", "ambiguous", "noprog"):
|
||||
result = self.invoke("fetch", mode=mode)
|
||||
self.assertNotEqual(result.returncode, 0, mode)
|
||||
self.assertEqual(result.stdout, "", mode)
|
||||
with mock.patch.object(feishu_bug_intake, "MAX_PAGES", 2):
|
||||
result = self.invoke("fetch", mode="max")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "")
|
||||
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", side_effect=feishu_bug_intake.IntakeError("missing trusted")):
|
||||
result = self.invoke("fetch", resolver=feishu_bug_intake.IntakeError("missing trusted"))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "")
|
||||
|
||||
def test_reference_template_example_contract_signals(self) -> None:
|
||||
paths = [ROOT / "skills/ack/references/feishu-bug-intake.md", ROOT / "skills/ack/templates/tasks.template.yaml", ROOT / "skills/ack/examples/tasks.example.yaml"]
|
||||
content = "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||
self.assertIn("feishu-base:sha256:", content)
|
||||
self.assertIn('npm install --global --prefix "$HOME/.local" @larksuite/cli@latest', content)
|
||||
self.assertIn("profile add", content)
|
||||
self.assertIn("--brand feishu", content)
|
||||
self.assertTrue("scope" in content)
|
||||
self.assertIn("不要把 `lark-cli auth check`", content)
|
||||
self.assertIn("审核通过前的唯一协作区", content)
|
||||
self.assertIn("不创建或刷新 `tasks.yaml` 中的 ACK 任务", content)
|
||||
self.assertIn("base:record:write", content)
|
||||
self.assertIn("problemStatement", content)
|
||||
self.assertIn("不在收件箱写修复逻辑", content)
|
||||
self.assertNotIn("--lark-cli", content)
|
||||
self.assertNotIn("--executable", content)
|
||||
|
||||
def test_plan_organizes_stable_sources_against_a_real_task_board(self) -> None:
|
||||
first = json.loads(self.invoke("fetch").stdout)["records"][0]["sourceRef"]
|
||||
second = json.loads(self.invoke("fetch").stdout)["records"][0]["sourceRef"]
|
||||
self.assertRegex(first, r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
self.assertEqual(first, second)
|
||||
empty_plan = json.loads(self.invoke("plan").stdout)
|
||||
self.assertEqual(empty_plan["actions"], [{
|
||||
"sourceRef": first,
|
||||
"recordId": "recBug1",
|
||||
"draftRevision": empty_plan["records"][0]["draftRevision"],
|
||||
"action": "create",
|
||||
}])
|
||||
self.assertRegex(
|
||||
empty_plan["actions"][0]["draftRevision"], r"^sha256:[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
base = self.board.read_text(encoding="utf-8")
|
||||
|
||||
def write_existing(status: str, updated_at: str, *, duplicate: bool = False) -> None:
|
||||
task = f"""tasks:
|
||||
- id: BUG-1
|
||||
title: Existing imported bug
|
||||
status: {status}
|
||||
source:
|
||||
kind: feishu-base
|
||||
ref: "{first}"
|
||||
recordId: recBug1
|
||||
updatedAt: "{updated_at}"
|
||||
"""
|
||||
if duplicate:
|
||||
task += f""" - id: BUG-2
|
||||
title: Duplicate imported bug
|
||||
status: open
|
||||
source:
|
||||
kind: feishu-base
|
||||
ref: "{first}"
|
||||
recordId: recBug1
|
||||
updatedAt: "{updated_at}"
|
||||
"""
|
||||
self.board.write_text(base.replace("tasks: []\n", task), encoding="utf-8")
|
||||
|
||||
write_existing("open", "2026-07-31T12:00:00Z")
|
||||
validator = ROOT / "skills/ack/scripts/validate_tasks.py"
|
||||
validated = subprocess.run(
|
||||
[sys.executable, str(validator), str(self.board)],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
self.assertEqual(validated.returncode, 0, validated.stderr)
|
||||
self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "refresh")
|
||||
|
||||
write_existing("verified", "2026-07-31T12:00:00Z")
|
||||
self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "drift")
|
||||
|
||||
write_existing("verified", "2026-08-01T12:00:00Z")
|
||||
self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "unchanged")
|
||||
|
||||
write_existing("open", "2026-07-31T12:00:00Z", duplicate=True)
|
||||
duplicate_result = self.invoke("plan")
|
||||
self.assertNotEqual(duplicate_result.returncode, 0)
|
||||
self.assertEqual(duplicate_result.stdout, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,645 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ACK_SCRIPTS = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||
sys.path.insert(0, str(ACK_SCRIPTS))
|
||||
import feishu_bug_intake # noqa: E402
|
||||
|
||||
|
||||
BOARD = """version: 1
|
||||
project:
|
||||
name: demo
|
||||
bugIntake:
|
||||
provider: feishu-base
|
||||
profile: tenant-b
|
||||
baseToken: bascnDemo
|
||||
tableId: tblDemo
|
||||
viewId: vewReady
|
||||
fields:
|
||||
title: fldTitle
|
||||
actual: fldActual
|
||||
expected: fldExpected
|
||||
stepsToReproduce: fldSteps
|
||||
acceptance: fldAcceptance
|
||||
priority: fldPriority
|
||||
attachments: fldAttachments
|
||||
updatedAt: fldUpdated
|
||||
tasks: []
|
||||
"""
|
||||
|
||||
CLARIFIED_BOARD = """version: 1
|
||||
project:
|
||||
name: demo
|
||||
bugIntake:
|
||||
provider: feishu-base
|
||||
workflow: clarified-writeback-v1
|
||||
profile: tenant-b
|
||||
baseToken: bascnDemo
|
||||
tableId: tblDemo
|
||||
viewId: vewReady
|
||||
fields:
|
||||
title: 标题
|
||||
details: 详细描述
|
||||
problemStatement: 问题说明
|
||||
expectedOutcome: 期望效果
|
||||
acceptance: 验收标准
|
||||
intakeStatus: 处理状态
|
||||
ackTaskId: ACK任务ID
|
||||
attachments: 附件
|
||||
updatedAt: 更新时间
|
||||
tasks: []
|
||||
"""
|
||||
|
||||
|
||||
class FeishuBugIntakeUnitTests(unittest.TestCase):
|
||||
def test_clarified_workflow_normalizes_only_source_and_clarification_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
board_path = Path(temp_dir) / "tasks.yaml"
|
||||
board_path.write_text(CLARIFIED_BOARD, encoding="utf-8")
|
||||
config = feishu_bug_intake.config_from_board(
|
||||
feishu_bug_intake.load_board(board_path)
|
||||
)
|
||||
row = [
|
||||
"Bug", "用户描述", "问题说明", "期望效果", "1. 可观察结果",
|
||||
"待审核", "", [], "2026-08-03T12:00:00Z",
|
||||
]
|
||||
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||
):
|
||||
record = feishu_bug_intake.fetch(config, None)["records"][0]
|
||||
self.assertEqual(record["details"], "用户描述")
|
||||
self.assertEqual(record["expectedOutcome"], "期望效果")
|
||||
self.assertNotIn("fixLogic", record)
|
||||
self.assertNotIn("priority", record)
|
||||
|
||||
def test_clarified_draft_contract_rejects_fix_logic(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "draft.json"
|
||||
path.write_text(json.dumps({
|
||||
"problemStatement": "问题说清楚",
|
||||
"expectedOutcome": "期望说清楚",
|
||||
"acceptance": ["结果可从界面观察"],
|
||||
}), encoding="utf-8")
|
||||
draft = feishu_bug_intake.load_draft(path, "clarified-writeback-v1")
|
||||
self.assertEqual(set(draft), {"problemStatement", "expectedOutcome", "acceptance"})
|
||||
path.write_text(json.dumps({**draft, "fixLogic": "不应出现"}), encoding="utf-8")
|
||||
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "exactly"):
|
||||
feishu_bug_intake.load_draft(path, "clarified-writeback-v1")
|
||||
|
||||
def test_schema_plan_preserves_legacy_fields_and_adds_new_contract(self) -> None:
|
||||
config = {
|
||||
"workflow": "clarified-writeback-v1",
|
||||
"profile": "tenant-b",
|
||||
"baseToken": "bascnDemo",
|
||||
"tableId": "tblDemo",
|
||||
"viewId": "vewReady",
|
||||
}
|
||||
fields = [
|
||||
{"id": "a", "name": "标题", "type": "text"},
|
||||
{"id": "b", "name": "详细描述", "type": "text"},
|
||||
{"id": "c", "name": "附件", "type": "attachment"},
|
||||
{"id": "d", "name": "验收标准", "type": "text"},
|
||||
{"id": "e", "name": "更新时间", "type": "updated_at"},
|
||||
{"id": "f", "name": "期望结果", "type": "text"},
|
||||
]
|
||||
with mock.patch.object(feishu_bug_intake, "field_list", return_value=fields):
|
||||
plan = feishu_bug_intake.schema_plan(config)
|
||||
self.assertEqual(plan["missingFields"], ["问题说明", "期望效果", "处理状态", "ACK任务ID"])
|
||||
self.assertIn("期望结果", plan["legacyFieldsPreserved"])
|
||||
self.assertEqual(plan["typeConflicts"], [])
|
||||
self.assertEqual(plan["target"]["tableId"], "tblDemo")
|
||||
changed_target = {**config, "tableId": "tblOther"}
|
||||
self.assertNotEqual(
|
||||
feishu_bug_intake.schema_fingerprint(config, fields),
|
||||
feishu_bug_intake.schema_fingerprint(changed_target, fields),
|
||||
)
|
||||
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "requires clarified"):
|
||||
feishu_bug_intake.schema_plan({**config, "workflow": "read-only-v1"})
|
||||
|
||||
def make_fake_cli(self, root: Path) -> tuple[Path, Path]:
|
||||
log_path = root / "calls.jsonl"
|
||||
fake = root / "lark-cli"
|
||||
fake.write_text(
|
||||
"#!" + sys.executable + "\n"
|
||||
"import json, pathlib, sys\n"
|
||||
f"log = pathlib.Path({str(log_path)!r})\n"
|
||||
"args = sys.argv[1:]\n"
|
||||
"with log.open('a') as f: f.write(json.dumps(args) + '\\n')\n"
|
||||
"if args[:2] == ['profile', 'list']:\n"
|
||||
" if '--format' in args: raise SystemExit(8)\n"
|
||||
" print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}]))\n"
|
||||
"elif args[:2] == ['base', '+record-list']:\n"
|
||||
" fields=['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated']; row=[' Bug\\n title ',' actual ','expected',['one','two'],'1. save succeeds\\n2. the crash no longer occurs','P1',[{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}],'2026-08-01']\n"
|
||||
" if 'fldFixLogic' in args: fields.append('fldFixLogic'); row.append('change parser without widening input')\n"
|
||||
" print(json.dumps({'code': 0, 'data': {'fields': fields, 'record_id_list': ['recA'], 'data': [row]}}))\n"
|
||||
"elif args[:2] == ['base', '+record-download-attachment']:\n"
|
||||
" out = pathlib.Path(args[args.index('--output') + 1]); out.mkdir(parents=True, exist_ok=True); (out / 'shot.png').write_bytes(b'png'); print(json.dumps({'ok': True, 'data': {}}))\n"
|
||||
"elif args[:2] == ['base', '+record-upsert']:\n"
|
||||
" print(json.dumps({'code': 0, 'data': {}}))\n"
|
||||
"else: raise SystemExit(2)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake.chmod(0o755)
|
||||
return fake, log_path
|
||||
|
||||
def invoke(self, argv: list[str], executable: Path) -> tuple[int, str, str]:
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=executable), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
code = feishu_bug_intake.main(argv)
|
||||
return code, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_reader_exposes_check_and_fetch_commands(self) -> None:
|
||||
output = io.StringIO()
|
||||
with self.assertRaises(SystemExit) as exited, contextlib.redirect_stdout(output):
|
||||
feishu_bug_intake.main(["--help"])
|
||||
self.assertEqual(exited.exception.code, 0)
|
||||
self.assertIn("mark-imported", output.getvalue())
|
||||
self.assertNotIn("confirm", output.getvalue())
|
||||
|
||||
def test_fetch_uses_mocked_trusted_executable_and_official_wire_shapes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
board_path = temp / "tasks.yaml"
|
||||
board_path.write_text(BOARD, encoding="utf-8")
|
||||
fake, log_path = self.make_fake_cli(temp)
|
||||
hostile = temp / "hostile"
|
||||
hostile.mkdir()
|
||||
(hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
|
||||
(hostile / "lark-cli").chmod(0o755)
|
||||
|
||||
with mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False):
|
||||
code, output, error = self.invoke(["fetch", str(board_path), "--output-dir", str(temp / "downloads")], fake)
|
||||
|
||||
self.assertEqual(code, 0, error)
|
||||
payload = json.loads(output)
|
||||
self.assertEqual(payload["records"][0]["title"], "Bug title")
|
||||
self.assertEqual(payload["records"][0]["steps"], "one\ntwo")
|
||||
self.assertRegex(payload["records"][0]["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
self.assertNotIn("bascnDemo", output)
|
||||
self.assertNotIn("fileA", output)
|
||||
self.assertNotIn("token", payload["records"][0]["attachments"][0])
|
||||
self.assertTrue(Path(payload["records"][0]["attachments"][0]["localPath"]).is_file())
|
||||
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(calls[0], ["profile", "list"])
|
||||
for call in calls:
|
||||
if call[:1] == ["base"]:
|
||||
self.assertEqual(call[call.index("--profile") + 1], "tenant-b")
|
||||
|
||||
def test_resolver_ignores_hostile_path_and_rejects_unsafe_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
trusted, hostile = temp / "trusted", temp / "hostile"
|
||||
trusted.mkdir()
|
||||
hostile.mkdir()
|
||||
safe = trusted / "lark-cli"
|
||||
safe.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
safe.chmod(0o755)
|
||||
(hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
|
||||
(hostile / "lark-cli").chmod(0o755)
|
||||
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]), mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False):
|
||||
self.assertEqual(feishu_bug_intake.resolve_lark_cli(), safe)
|
||||
safe.chmod(0o775)
|
||||
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
|
||||
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "trusted"):
|
||||
feishu_bug_intake.resolve_lark_cli()
|
||||
|
||||
def test_resolver_accepts_only_the_official_npm_wrapper_shape(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
trusted = temp / "trusted"
|
||||
scripts = temp / "node_modules" / "@larksuite" / "cli" / "scripts"
|
||||
trusted.mkdir()
|
||||
scripts.mkdir(parents=True)
|
||||
wrapper = scripts / "run.js"
|
||||
wrapper.write_text(
|
||||
"#!" + sys.executable + "\nimport json\nprint(json.dumps({'ok': True}))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
manifest = scripts.parent / "package.json"
|
||||
manifest.write_text(json.dumps({
|
||||
"name": "@larksuite/cli",
|
||||
"bin": {"lark-cli": "scripts/run.js"},
|
||||
}), encoding="utf-8")
|
||||
native = scripts.parent / "bin" / "lark-cli"
|
||||
native.parent.mkdir()
|
||||
native.write_text(
|
||||
"#!" + sys.executable + "\nimport json\nprint(json.dumps({'ok': True}))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
native.chmod(0o755)
|
||||
(trusted / "lark-cli").symlink_to(wrapper)
|
||||
|
||||
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
|
||||
self.assertEqual(feishu_bug_intake.resolve_lark_cli(), native)
|
||||
self.assertEqual(feishu_bug_intake.run_cli(["probe"]), {"ok": True})
|
||||
|
||||
manifest.write_text(json.dumps({
|
||||
"name": "lookalike",
|
||||
"bin": {"lark-cli": "scripts/run.js"},
|
||||
}), encoding="utf-8")
|
||||
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.resolve_lark_cli()
|
||||
|
||||
def test_cli_environment_drops_credential_and_runtime_overrides(self) -> None:
|
||||
hostile = {
|
||||
"LARKSUITE_CLI_APP_ID": "wrong-app",
|
||||
"LARKSUITE_CLI_APP_SECRET": "wrong-secret",
|
||||
"LARKSUITE_CLI_CONFIG_DIR": "/tmp/wrong-config",
|
||||
"LARKSUITE_CLI_BRAND": "lark",
|
||||
"FEISHU_APP_SECRET": "wrong-feishu-secret",
|
||||
"NODE_OPTIONS": "--require=/tmp/inject.js",
|
||||
"PYTHONPATH": "/tmp/inject",
|
||||
}
|
||||
with mock.patch.dict(os.environ, hostile, clear=False):
|
||||
environment = feishu_bug_intake.cli_environment()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
fake = Path(temp_dir) / "lark-cli"
|
||||
fake.write_text(
|
||||
"#!" + sys.executable + "\n"
|
||||
"import json, os\n"
|
||||
"keys = ['LARKSUITE_CLI_APP_ID','LARKSUITE_CLI_APP_SECRET','LARKSUITE_CLI_CONFIG_DIR','LARKSUITE_CLI_BRAND','FEISHU_APP_SECRET','NODE_OPTIONS','PYTHONPATH']\n"
|
||||
"print(json.dumps({'ok': True, 'data': {key: os.environ.get(key) for key in keys}}))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake.chmod(0o755)
|
||||
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=fake):
|
||||
child = feishu_bug_intake.run_cli(["probe"])
|
||||
self.assertEqual(set(environment) - {"HOME", "PATH"}, set(environment) & {"LANG", "LC_ALL", "LC_CTYPE"})
|
||||
for name in hostile:
|
||||
self.assertNotIn(name, environment)
|
||||
self.assertIsNone(child["data"][name])
|
||||
|
||||
def test_run_cli_rejects_error_and_ambiguous_envelopes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
fake = temp / "lark-cli"
|
||||
fake.write_text(
|
||||
"#!" + sys.executable + "\nimport json, sys\nprint(sys.argv[1])\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake.chmod(0o755)
|
||||
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=fake):
|
||||
for response in ('{"ok":false}', '{"code":7}', '{"data":{}}'):
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.run_cli([response])
|
||||
|
||||
def test_matrix_accepts_official_ok_envelope_and_data_rows(self) -> None:
|
||||
fields = ["fldTitle"]
|
||||
response = {
|
||||
"ok": True,
|
||||
"data": {"fields": fields, "record_id_list": ["recA"], "data": [["Bug"]]},
|
||||
}
|
||||
self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]]))
|
||||
|
||||
def test_matrix_reorders_same_field_projection(self) -> None:
|
||||
response = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"fields": ["fldExpected", "fldTitle"],
|
||||
"record_id_list": ["recA"],
|
||||
"data": [["expected", "Bug"]],
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
feishu_bug_intake.matrix_from_response(
|
||||
response,
|
||||
["fldTitle", "fldExpected"],
|
||||
),
|
||||
(["recA"], [["Bug", "expected"]]),
|
||||
)
|
||||
|
||||
def test_matrix_rejects_different_field_projection_with_diagnostics(self) -> None:
|
||||
response = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"fields": ["fldActual"],
|
||||
"record_id_list": ["recA"],
|
||||
"data": [["actual"]],
|
||||
},
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
feishu_bug_intake.IntakeError,
|
||||
"expected=.*fldTitle.*actual=.*fldActual",
|
||||
):
|
||||
feishu_bug_intake.matrix_from_response(response, ["fldTitle"])
|
||||
|
||||
def test_missing_priority_mapping_is_normalized_as_enrichment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
board_path = Path(temp_dir) / "tasks.yaml"
|
||||
board_path.write_text(
|
||||
BOARD.replace(" priority: fldPriority\n", ""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = feishu_bug_intake.config_from_board(
|
||||
feishu_bug_intake.load_board(board_path)
|
||||
)
|
||||
row = [
|
||||
"Bug", "actual", "expected", "steps", "acceptance", [],
|
||||
"2026-08-01T12:00:00Z",
|
||||
]
|
||||
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||
):
|
||||
payload = feishu_bug_intake.fetch(config, None)
|
||||
|
||||
self.assertEqual(payload["records"][0]["priority"], "")
|
||||
self.assertIn("priority", payload["records"][0]["enrichmentRequired"])
|
||||
|
||||
def test_reviewed_workflow_requires_priority_mapping(self) -> None:
|
||||
board = feishu_bug_intake.load_yaml_subset(
|
||||
BOARD.replace(
|
||||
" profile: tenant-b\n",
|
||||
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
|
||||
).replace(
|
||||
" acceptance: fldAcceptance\n",
|
||||
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
|
||||
).replace(" priority: fldPriority\n", "")
|
||||
)
|
||||
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "fixLogic and priority"):
|
||||
feishu_bug_intake.config_from_board(board)
|
||||
|
||||
def test_fully_blank_clarified_row_is_skipped_without_key_error(self) -> None:
|
||||
config = feishu_bug_intake.config_from_board(
|
||||
feishu_bug_intake.load_yaml_subset(CLARIFIED_BOARD)
|
||||
)
|
||||
row = ["", "", "", "", "", "", "", [], ""]
|
||||
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||
):
|
||||
payload = feishu_bug_intake.fetch(config, None)
|
||||
self.assertEqual(payload["records"], [])
|
||||
self.assertEqual(payload["warnings"][0]["code"], "blank_record_skipped")
|
||||
|
||||
def test_mark_imported_binds_confirmed_record_to_existing_task(self) -> None:
|
||||
source_ref = "feishu-base:sha256:" + "a" * 64
|
||||
revision = "sha256:" + "b" * 64
|
||||
task = {
|
||||
"id": "BUG-001",
|
||||
"title": "Bug",
|
||||
"description": "问题说明",
|
||||
"actual": "用户描述",
|
||||
"expected": "期望效果",
|
||||
"acceptanceCriteria": ["结果可观察"],
|
||||
"source": {
|
||||
"kind": "feishu-base",
|
||||
"workflow": "clarified-writeback-v1",
|
||||
"ref": source_ref,
|
||||
"recordId": "recA",
|
||||
"updatedAt": "2026-08-04T10:00:00Z",
|
||||
"approvedRevision": revision,
|
||||
},
|
||||
}
|
||||
task["source"]["approvedPayloadHash"] = feishu_bug_intake.approval_payload_hash(task)
|
||||
board = {"tasks": [task]}
|
||||
config = feishu_bug_intake.config_from_board(
|
||||
feishu_bug_intake.load_yaml_subset(CLARIFIED_BOARD)
|
||||
)
|
||||
confirmed = {
|
||||
"recordId": "recA", "sourceRef": source_ref,
|
||||
"draftRevision": revision, "intakeStatus": "已确认", "ackTaskId": "",
|
||||
"title": "Bug", "details": "用户描述",
|
||||
"problemStatement": "问题说明", "expectedOutcome": "期望效果",
|
||||
"acceptance": "1. 结果可观察", "updatedAt": "2026-08-04T10:00:00Z",
|
||||
}
|
||||
imported = {
|
||||
**confirmed, "intakeStatus": "已导入", "ackTaskId": "BUG-001",
|
||||
}
|
||||
with mock.patch.object(
|
||||
feishu_bug_intake, "review_record", return_value=confirmed,
|
||||
), mock.patch.object(
|
||||
feishu_bug_intake, "validate_task_board", return_value=[],
|
||||
), mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||
feishu_bug_intake, "run_cli", return_value={"ok": True},
|
||||
) as run_cli, mock.patch.object(
|
||||
feishu_bug_intake, "fetch", return_value={"records": [imported]},
|
||||
):
|
||||
result = feishu_bug_intake.mark_imported(
|
||||
board, config, "recA", source_ref, revision, "BUG-001",
|
||||
)
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
patch = json.loads(run_cli.call_args.args[0][run_cli.call_args.args[0].index("--json") + 1])
|
||||
self.assertEqual(patch, {"处理状态": "已导入", "ACK任务ID": "BUG-001"})
|
||||
|
||||
tampered = json.loads(json.dumps(board, ensure_ascii=False))
|
||||
tampered["tasks"][0]["description"] = "未批准的改写"
|
||||
tampered["tasks"][0]["source"]["approvedPayloadHash"] = (
|
||||
feishu_bug_intake.approval_payload_hash(tampered["tasks"][0])
|
||||
)
|
||||
with mock.patch.object(
|
||||
feishu_bug_intake, "review_record", return_value=confirmed,
|
||||
), mock.patch.object(
|
||||
feishu_bug_intake, "validate_task_board", return_value=[],
|
||||
), mock.patch.object(feishu_bug_intake, "run_cli") as blocked_write:
|
||||
with self.assertRaisesRegex(
|
||||
feishu_bug_intake.IntakeError, "does not match the approved",
|
||||
):
|
||||
feishu_bug_intake.mark_imported(
|
||||
tampered, config, "recA", source_ref, revision, "BUG-001",
|
||||
)
|
||||
blocked_write.assert_not_called()
|
||||
|
||||
with mock.patch.object(
|
||||
feishu_bug_intake, "validate_task_board", return_value=["invalid"],
|
||||
), mock.patch.object(feishu_bug_intake, "review_record") as blocked_read:
|
||||
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "board is invalid"):
|
||||
feishu_bug_intake.mark_imported(
|
||||
board, config, "recA", source_ref, revision, "BUG-001",
|
||||
)
|
||||
blocked_read.assert_not_called()
|
||||
|
||||
def test_optional_fix_logic_field_is_normalized_for_preapproval_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
board_path = Path(temp_dir) / "tasks.yaml"
|
||||
board_path.write_text(
|
||||
BOARD.replace(
|
||||
" profile: tenant-b\n",
|
||||
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
|
||||
).replace(
|
||||
" acceptance: fldAcceptance\n",
|
||||
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = feishu_bug_intake.config_from_board(
|
||||
feishu_bug_intake.load_board(board_path)
|
||||
)
|
||||
row = [
|
||||
"Bug", "actual", "expected", "steps", "acceptance", "P1", [],
|
||||
"2026-08-01T12:00:00Z", "change parser without widening input",
|
||||
]
|
||||
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
|
||||
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
|
||||
):
|
||||
payload = feishu_bug_intake.fetch(config, None)
|
||||
|
||||
record = payload["records"][0]
|
||||
self.assertEqual(record["fixLogic"], "change parser without widening input")
|
||||
self.assertNotIn("fixLogic", record["enrichmentRequired"])
|
||||
|
||||
def test_write_draft_uses_trusted_cli_and_only_configured_review_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
board_path = temp / "tasks.yaml"
|
||||
board_path.write_text(
|
||||
BOARD.replace(
|
||||
" profile: tenant-b\n",
|
||||
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
|
||||
).replace(
|
||||
" acceptance: fldAcceptance\n",
|
||||
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
draft_path = temp / "draft.json"
|
||||
draft_path.write_text(json.dumps({
|
||||
"fixLogic": "change parser without widening input",
|
||||
"acceptance": ["save succeeds", "the crash no longer occurs"],
|
||||
}), encoding="utf-8")
|
||||
fake, log_path = self.make_fake_cli(temp)
|
||||
|
||||
fetched_code, fetched_output, fetched_error = self.invoke([
|
||||
"fetch", str(board_path),
|
||||
], fake)
|
||||
self.assertEqual(fetched_code, 0, fetched_error)
|
||||
fetched_record = json.loads(fetched_output)["records"][0]
|
||||
|
||||
code, output, error = self.invoke([
|
||||
"write-draft", str(board_path), "--record-id", "recA",
|
||||
"--expected-source-ref", fetched_record["sourceRef"],
|
||||
"--expected-draft-revision", fetched_record["draftRevision"],
|
||||
"--input", str(draft_path),
|
||||
], fake)
|
||||
|
||||
self.assertEqual(code, 0, error)
|
||||
self.assertEqual(json.loads(output)["written"], ["fixLogic", "acceptance"])
|
||||
self.assertRegex(json.loads(output)["draftRevision"], r"^sha256:[0-9a-f]{64}$")
|
||||
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
|
||||
write_call = next(call for call in calls if call[:2] == ["base", "+record-upsert"])
|
||||
self.assertEqual(write_call[write_call.index("--profile") + 1], "tenant-b")
|
||||
patch = json.loads(write_call[write_call.index("--json") + 1])
|
||||
self.assertEqual(set(patch), {"fldFixLogic", "fldAcceptance"})
|
||||
self.assertIn("1. save succeeds", patch["fldAcceptance"])
|
||||
|
||||
imported_code, imported_output, imported_error = self.invoke([
|
||||
"import-approved", str(board_path), "--record-id", "recA",
|
||||
"--expected-source-ref", fetched_record["sourceRef"],
|
||||
"--expected-draft-revision", fetched_record["draftRevision"],
|
||||
], fake)
|
||||
self.assertEqual(imported_code, 0, imported_error)
|
||||
task_draft = json.loads(imported_output)["taskDraft"]
|
||||
self.assertEqual(task_draft["source"]["workflow"], "reviewed-writeback-v1")
|
||||
self.assertEqual(
|
||||
task_draft["acceptanceCriteria"],
|
||||
["save succeeds", "the crash no longer occurs"],
|
||||
)
|
||||
self.assertRegex(
|
||||
task_draft["source"]["approvedPayloadHash"], r"^sha256:[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
def test_write_draft_rejects_stale_revision_before_mutation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
board_path = temp / "tasks.yaml"
|
||||
board_path.write_text(
|
||||
BOARD.replace(
|
||||
" profile: tenant-b\n",
|
||||
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
|
||||
).replace(
|
||||
" acceptance: fldAcceptance\n",
|
||||
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
draft_path = temp / "draft.json"
|
||||
draft_path.write_text(json.dumps({
|
||||
"fixLogic": "fix",
|
||||
"acceptance": ["pass"],
|
||||
}), encoding="utf-8")
|
||||
fake, log_path = self.make_fake_cli(temp)
|
||||
fetched = json.loads(self.invoke(["fetch", str(board_path)], fake)[1])["records"][0]
|
||||
|
||||
code, _, _ = self.invoke([
|
||||
"write-draft", str(board_path), "--record-id", "recA",
|
||||
"--expected-source-ref", fetched["sourceRef"],
|
||||
"--expected-draft-revision", "sha256:" + "0" * 64,
|
||||
"--input", str(draft_path),
|
||||
], fake)
|
||||
|
||||
self.assertNotEqual(code, 0)
|
||||
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertFalse(any(call[:2] == ["base", "+record-upsert"] for call in calls))
|
||||
|
||||
def test_write_draft_rejects_symlink_and_unknown_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
target = temp / "draft.json"
|
||||
target.write_text(json.dumps({
|
||||
"fixLogic": "fix",
|
||||
"acceptance": ["pass"],
|
||||
"unexpected": "do not write",
|
||||
}), encoding="utf-8")
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.load_draft(target)
|
||||
|
||||
target.write_text(json.dumps({
|
||||
"fixLogic": "fix",
|
||||
"acceptance": ["pass"],
|
||||
}), encoding="utf-8")
|
||||
linked = temp / "linked.json"
|
||||
linked.symlink_to(target)
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.load_draft(linked)
|
||||
|
||||
def test_draft_revision_binds_attachment_identity(self) -> None:
|
||||
record = {
|
||||
"sourceRef": "feishu-base:sha256:" + "a" * 64,
|
||||
"updatedAt": "2026-08-03T12:00:00+08:00",
|
||||
"title": "Bug",
|
||||
"actual": "crash",
|
||||
"expected": "save",
|
||||
"steps": "open then save",
|
||||
"fixLogic": "fix parser",
|
||||
"acceptance": "save succeeds",
|
||||
"priority": "P1",
|
||||
"attachments": [{"name": "same.png", "type": "image/png", "size": 4}],
|
||||
}
|
||||
first = feishu_bug_intake.draft_revision(record, ["fileTokenA"])
|
||||
second = feishu_bug_intake.draft_revision(record, ["fileTokenB"])
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
def test_attachments_and_required_text_are_resource_bounded_and_strict(self) -> None:
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.attachment_items([{
|
||||
"file_token": "fileA",
|
||||
"name": "huge.png",
|
||||
"type": "image/png",
|
||||
"size": feishu_bug_intake.MAX_ATTACHMENT_BYTES + 1,
|
||||
}])
|
||||
too_many = [
|
||||
{"file_token": f"file{index}", "name": f"{index}.png", "size": 1}
|
||||
for index in range(feishu_bug_intake.MAX_ATTACHMENTS_PER_RECORD + 1)
|
||||
]
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.attachment_items(too_many)
|
||||
with self.assertRaises(feishu_bug_intake.IntakeError):
|
||||
feishu_bug_intake.text({"unexpected": "value"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -612,7 +612,7 @@ class AckKnowledgeTests(unittest.TestCase):
|
||||
)
|
||||
self.assertFalse(any("knowledgeFile" in error for error in errors))
|
||||
|
||||
def test_cli_fails_closed_for_missing_repo_root_and_wrong_binding(self) -> None:
|
||||
def test_cli_uses_tasks_layout_instead_of_stale_repo_path(self) -> None:
|
||||
data = example_data()
|
||||
task = {
|
||||
"id": "BUG-002",
|
||||
@@ -683,7 +683,7 @@ class AckKnowledgeTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(inferred.returncode, 1)
|
||||
self.assertIn("project.repoPath", inferred.stderr)
|
||||
self.assertNotIn("project.repoPath", inferred.stderr)
|
||||
self.assertIn("与当前知识文件", inferred.stderr)
|
||||
self.assertIn("symlink", inferred.stderr)
|
||||
self.assertEqual(explicit.returncode, 1)
|
||||
|
||||
@@ -65,12 +65,20 @@ def orchestration() -> dict:
|
||||
|
||||
def board(project_root: Path) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"project": {
|
||||
"name": "test-project",
|
||||
"repoPath": str(project_root),
|
||||
"orchestration": orchestration(),
|
||||
},
|
||||
"workerReceipts": [],
|
||||
"tasks": [{"id": "TASK-001"}],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-001",
|
||||
"title": "test task",
|
||||
"status": "open",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +98,8 @@ def worktree_identity(path: Path) -> dict:
|
||||
|
||||
def plan_for(path: Path, executable: Path) -> dict:
|
||||
profile = orchestration()["profiles"]["codex-dev-standard"]
|
||||
task_board = board(path)
|
||||
board_hash = worker_profiles.canonical_sha256(task_board)
|
||||
identity = worktree_identity(path)
|
||||
executable_metadata = executable.stat()
|
||||
argv = worker_profiles.render_worker_argv(
|
||||
@@ -121,6 +131,8 @@ def plan_for(path: Path, executable: Path) -> dict:
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(path),
|
||||
"boardHash": board_hash,
|
||||
"profileId": "codex-dev-standard",
|
||||
"profileHash": profile_digest,
|
||||
"createdFor": created_for,
|
||||
@@ -134,6 +146,7 @@ def plan_for(path: Path, executable: Path) -> dict:
|
||||
"protocolVersion": 1,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(path),
|
||||
"boardHash": board_hash,
|
||||
"taskId": "TASK-001",
|
||||
"attemptId": "TASK-001-A1",
|
||||
"role": "developer",
|
||||
@@ -362,6 +375,44 @@ class EnvironmentAndExecutableTests(unittest.TestCase):
|
||||
|
||||
|
||||
class PlanTests(unittest.TestCase):
|
||||
def test_authoritative_board_is_derived_from_project_root_without_repo_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
project = Path(temporary).resolve()
|
||||
ack_dir = project / "docs" / "ack"
|
||||
ack_dir.mkdir(parents=True)
|
||||
task_board = board(project)
|
||||
del task_board["project"]["repoPath"]
|
||||
(ack_dir / "tasks.yaml").write_text(
|
||||
json.dumps(task_board),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded_root, loaded_board = launch_worker.load_authoritative_board(
|
||||
str(project)
|
||||
)
|
||||
|
||||
self.assertEqual(loaded_root, project)
|
||||
self.assertEqual(loaded_board, task_board)
|
||||
|
||||
def test_authoritative_board_ignores_legacy_repo_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
project = Path(temporary).resolve()
|
||||
ack_dir = project / "docs" / "ack"
|
||||
ack_dir.mkdir(parents=True)
|
||||
task_board = board(project)
|
||||
task_board["project"]["repoPath"] = "/legacy/other-worktree"
|
||||
(ack_dir / "tasks.yaml").write_text(
|
||||
json.dumps(task_board),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded_root, loaded_board = launch_worker.load_authoritative_board(
|
||||
str(project)
|
||||
)
|
||||
|
||||
self.assertEqual(loaded_root, project)
|
||||
self.assertEqual(loaded_board, task_board)
|
||||
|
||||
def test_plan_uses_exact_renderer_and_binds_created_for(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
project = Path(temporary).resolve()
|
||||
@@ -410,6 +461,8 @@ class PlanTests(unittest.TestCase):
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(project),
|
||||
"boardHash": worker_profiles.canonical_sha256(task_board),
|
||||
"profileId": plan["profileId"],
|
||||
"profileHash": plan["profileHash"],
|
||||
"createdFor": {
|
||||
|
||||
+84
-1
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,6 +22,7 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"validate_tasks.py",
|
||||
"validate_knowledge.py",
|
||||
"validate_delivery.py",
|
||||
"select_tasks.py",
|
||||
"select_knowledge.py",
|
||||
"references/kickoff.md",
|
||||
"不要修改项目的 `AGENTS.md`",
|
||||
@@ -46,6 +48,66 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
self.assertIn("禁止根据持久化 receipt 自动复用", content)
|
||||
self.assertIn("launcher 身份证明", content)
|
||||
|
||||
def test_worker_reuse_requires_idle_state_and_verified_history_reset(self) -> None:
|
||||
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
adapter = (
|
||||
REPO_ROOT / "skills" / "ack" / "references" / "orca-adapter.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("空闲", skill)
|
||||
self.assertIn("清理历史消息", skill)
|
||||
self.assertIn("无法确认清理成功时创建 fresh worker", skill)
|
||||
self.assertIn("角色、profile、worktree", adapter)
|
||||
self.assertIn("不得复用仍在工作", adapter)
|
||||
self.assertIn("或运行状态不明的 worker", adapter)
|
||||
|
||||
def test_coordinator_reclaims_only_verified_task_terminals_at_run_end(self) -> None:
|
||||
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
kickoff = (
|
||||
REPO_ROOT / "skills" / "ack" / "references" / "kickoff.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("整轮任务完成", skill)
|
||||
self.assertIn("回收所有只属于 `verified` 任务的 worker", skill)
|
||||
self.assertIn("终端,并核对关闭回执", skill)
|
||||
self.assertIn("不设置 TTL", skill)
|
||||
self.assertIn("blocked", kickoff)
|
||||
self.assertIn("failed_retest", kickoff)
|
||||
self.assertIn("leftover", kickoff)
|
||||
|
||||
def test_environment_failures_are_reported_without_consuming_retest_rounds(self) -> None:
|
||||
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
optimization = (
|
||||
REPO_ROOT / "skills" / "ack" / "references" / "optimization-method.md"
|
||||
).read_text(encoding="utf-8")
|
||||
schema = json.loads(
|
||||
(REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
self.assertIn("环境失败不占复验轮次", skill)
|
||||
self.assertIn("userAction", optimization)
|
||||
self.assertIn("environmentIncidents", schema["definitions"]["task"]["properties"]["dispatch"]["properties"])
|
||||
|
||||
def test_validation_ready_hands_off_a_deployed_test_environment(self) -> None:
|
||||
skill = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
delivery = (
|
||||
REPO_ROOT / "skills" / "ack" / "references" / "delivery.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("不能停在", skill)
|
||||
self.assertIn("`verified` 却声称整轮 ACK 已结束", skill)
|
||||
self.assertIn("validation_ready", delivery)
|
||||
self.assertIn("访问地址和用户下一步", delivery)
|
||||
|
||||
def test_ack_knowledge_resources_and_version_are_present(self) -> None:
|
||||
ack_dir = REPO_ROOT / "skills" / "ack"
|
||||
|
||||
@@ -54,6 +116,7 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"templates/knowledge.schema.json",
|
||||
"examples/knowledge.example.yaml",
|
||||
"scripts/validate_knowledge.py",
|
||||
"scripts/select_tasks.py",
|
||||
"scripts/select_knowledge.py",
|
||||
"scripts/run_verification.py",
|
||||
"scripts/worker_profiles.py",
|
||||
@@ -64,13 +127,33 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"references/delivery.md",
|
||||
):
|
||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.11.0")
|
||||
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
||||
self.assertEqual(version, "0.15.0")
|
||||
self.assertIn(
|
||||
f'ackVersion: "{version}"',
|
||||
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertIn(
|
||||
f"ack v{version}",
|
||||
(ack_dir / "examples" / "project.example.md").read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertIn(
|
||||
'ackVersion: "<接入时的 ack skill 版本>"',
|
||||
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
)
|
||||
self.assertTrue((ack_dir / "references" / "feishu-bug-intake.md").is_file())
|
||||
|
||||
def test_task_template_derives_project_files_without_persisted_root_paths(self) -> None:
|
||||
template = (
|
||||
REPO_ROOT / "skills" / "ack" / "templates" / "tasks.template.yaml"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertNotIn("repoPath:", template)
|
||||
self.assertNotIn("devWorktree:", template)
|
||||
self.assertIn("allowedWorktrees:", template)
|
||||
self.assertIn('knowledgeFile: "docs/ack/knowledge.yaml"', template)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SELECTOR = REPO_ROOT / "skills" / "ack" / "scripts" / "select_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
SPEC = importlib.util.spec_from_file_location("ack_select_tasks", SELECTOR)
|
||||
assert SPEC and SPEC.loader
|
||||
SELECT_TASKS = importlib.util.module_from_spec(SPEC)
|
||||
sys.path.insert(0, str(SELECTOR.parent))
|
||||
SPEC.loader.exec_module(SELECT_TASKS)
|
||||
|
||||
|
||||
def board() -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"updatedAt": "2026-08-01T20:00:00+08:00",
|
||||
"source": "Coordinator",
|
||||
"project": {"name": "demo"},
|
||||
"summary": {"open": ["T-OPEN"], "verified": ["T-DONE"]},
|
||||
"tasks": [
|
||||
{"id": "T-OPEN", "title": "open", "status": "open"},
|
||||
{"id": "T-DEV", "title": "dev", "status": "fixed_by_dev"},
|
||||
{"id": "T-BLOCKED", "title": "blocked", "status": "blocked"},
|
||||
{"id": "T-DONE", "title": "done", "status": "verified"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class AckTaskSelectorTests(unittest.TestCase):
|
||||
def run_selector(
|
||||
self,
|
||||
value: dict,
|
||||
*args: str,
|
||||
no_site_packages: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "tasks.json"
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
command = [sys.executable]
|
||||
if no_site_packages:
|
||||
command.append("-S")
|
||||
command.extend((str(SELECTOR), str(path), *args))
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_default_selects_only_actionable_tasks_in_all_modes(self) -> None:
|
||||
for no_site_packages in (False, True):
|
||||
with self.subTest(no_site_packages=no_site_packages):
|
||||
result = self.run_selector(board(), no_site_packages=no_site_packages)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(
|
||||
payload["selection"]["selectedTaskIds"],
|
||||
["T-OPEN", "T-DEV"],
|
||||
)
|
||||
self.assertEqual(payload["selection"]["totalTasks"], 4)
|
||||
self.assertEqual(payload["project"]["name"], "demo")
|
||||
self.assertEqual(payload["summary"]["open"], ["T-OPEN"])
|
||||
|
||||
def test_example_yaml_runs_without_site_packages(self) -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-S", str(SELECTOR), str(EXAMPLE), "--compact"],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["project"]["name"], "notes-web")
|
||||
self.assertEqual(payload["selection"]["mode"], "statuses")
|
||||
|
||||
def test_explicit_task_id_selects_terminal_task(self) -> None:
|
||||
result = self.run_selector(board(), "--task-id", "T-DONE")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["selection"]["mode"], "task_ids")
|
||||
self.assertEqual(payload["selection"]["selectedTaskIds"], ["T-DONE"])
|
||||
self.assertEqual([task["id"] for task in payload["tasks"]], ["T-DONE"])
|
||||
|
||||
def test_missing_task_and_over_budget_fail_closed(self) -> None:
|
||||
missing = self.run_selector(board(), "--task-id", "T-MISSING")
|
||||
self.assertEqual(missing.returncode, 1)
|
||||
self.assertIn("找不到任务", missing.stderr)
|
||||
|
||||
over_budget = self.run_selector(board(), "--limit", "1")
|
||||
self.assertEqual(over_budget.returncode, 1)
|
||||
self.assertIn("超过 --limit=1", over_budget.stderr)
|
||||
|
||||
def test_invalid_board_is_rejected_before_selection(self) -> None:
|
||||
invalid = board()
|
||||
invalid["tasks"].append(
|
||||
{"id": "T-OPEN", "title": "duplicate", "status": "open"}
|
||||
)
|
||||
result = self.run_selector(invalid)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("任务板无效", result.stderr)
|
||||
self.assertIn("id 重复", result.stderr)
|
||||
|
||||
def test_receipts_and_delivery_runs_are_reduced_to_selected_tasks(self) -> None:
|
||||
selected = [
|
||||
{
|
||||
"id": "T-1",
|
||||
"dispatch": {
|
||||
"developer": {"receiptId": "WR-1"},
|
||||
"test": {"receiptId": None},
|
||||
},
|
||||
}
|
||||
]
|
||||
data = {
|
||||
"workerReceipts": [{"id": "WR-1"}, {"id": "WR-2"}],
|
||||
"deliveryRuns": [
|
||||
{"id": "DR-1", "taskIds": ["T-1"]},
|
||||
{"id": "DR-2", "taskIds": ["T-2"]},
|
||||
],
|
||||
}
|
||||
self.assertEqual(
|
||||
SELECT_TASKS.select_referenced_receipts(data, selected),
|
||||
[{"id": "WR-1"}],
|
||||
)
|
||||
self.assertEqual(
|
||||
SELECT_TASKS.select_delivery_runs(data, selected),
|
||||
[{"id": "DR-1", "taskIds": ["T-1"]}],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,6 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json"
|
||||
sys.path.insert(0, str(VALIDATOR.parent))
|
||||
from approval_payload import approval_payload_hash # noqa: E402
|
||||
|
||||
|
||||
def valid_knowledge_board() -> dict:
|
||||
@@ -148,6 +150,126 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_approved_feishu_plan_fields_are_validated(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["fixLogic"] = "change the parser and preserve legacy input"
|
||||
board["tasks"][0]["acceptanceCriteria"] = [
|
||||
"the submitted value is saved",
|
||||
"the original crash no longer occurs",
|
||||
]
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
invalid = copy.deepcopy(board)
|
||||
invalid["tasks"][0]["acceptanceCriteria"] = ["valid", 7]
|
||||
self.assert_board_rejected_in_all_modes(invalid, "列表项必须是字符串")
|
||||
|
||||
def test_reviewed_feishu_workflow_requires_fix_logic_and_approval_revision(self) -> None:
|
||||
fields = {
|
||||
"title": "fTitle",
|
||||
"actual": "fActual",
|
||||
"expected": "fExpected",
|
||||
"stepsToReproduce": "fSteps",
|
||||
"acceptance": "fAcceptance",
|
||||
"priority": "fPriority",
|
||||
"attachments": "fAttachments",
|
||||
"updatedAt": "fUpdated",
|
||||
}
|
||||
board = valid_knowledge_board()
|
||||
board["project"]["bugIntake"] = {
|
||||
"provider": "feishu-base",
|
||||
"workflow": "reviewed-writeback-v1",
|
||||
"profile": "tenant-b",
|
||||
"baseToken": "baseToken",
|
||||
"tableId": "tblBugs",
|
||||
"viewId": "vewReview",
|
||||
"fields": fields,
|
||||
}
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board, "reviewed-writeback-v1 必须映射",
|
||||
)
|
||||
|
||||
board["project"]["bugIntake"]["fields"]["fixLogic"] = "fFixLogic"
|
||||
board["tasks"][0].update({
|
||||
"priority": "P1",
|
||||
"actual": "save crashes",
|
||||
"expected": "save succeeds",
|
||||
"stepsToReproduce": ["open", "save"],
|
||||
"fixLogic": "preserve input while fixing the parser",
|
||||
"acceptanceCriteria": ["save succeeds", "the crash no longer occurs"],
|
||||
})
|
||||
board["tasks"][0]["source"] = {
|
||||
"kind": "feishu-base",
|
||||
"workflow": "reviewed-writeback-v1",
|
||||
"ref": "feishu-base:sha256:" + "a" * 64,
|
||||
"recordId": "recA",
|
||||
"updatedAt": "2026-08-03T12:00:00+08:00",
|
||||
"approvedRevision": "sha256:" + "b" * 64,
|
||||
}
|
||||
board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash(
|
||||
board["tasks"][0]
|
||||
)
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
board["tasks"][0]["source"]["approvedRevision"] = "latest"
|
||||
self.assert_board_rejected_in_all_modes(board, "必须是 sha256 revision")
|
||||
|
||||
board["tasks"][0]["source"]["approvedRevision"] = "sha256:" + "b" * 64
|
||||
board["tasks"][0]["fixLogic"] = "silently changed after approval"
|
||||
self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配")
|
||||
|
||||
board["tasks"][0]["fixLogic"] = "preserve input while fixing the parser"
|
||||
board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash(
|
||||
board["tasks"][0]
|
||||
)
|
||||
board["tasks"][0]["description"] = "modify unrelated modules"
|
||||
self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配")
|
||||
|
||||
legacy = copy.deepcopy(board)
|
||||
legacy["tasks"][0]["description"] = "validate knowledge fields"
|
||||
legacy["tasks"][0]["source"] = {
|
||||
"kind": "feishu-base",
|
||||
"ref": "feishu-base:sha256:" + "c" * 64,
|
||||
"recordId": "recLegacy",
|
||||
"updatedAt": "2026-08-01T12:00:00+08:00",
|
||||
}
|
||||
self.assert_board_rejected_in_all_modes(legacy, "必须先迁移审核")
|
||||
|
||||
legacy["tasks"][0]["status"] = "verified"
|
||||
self.assert_board_accepted_in_all_modes(legacy)
|
||||
|
||||
def test_legacy_sources_remain_open_while_feishu_sources_are_strict(self) -> None:
|
||||
for legacy_source in (
|
||||
"manual",
|
||||
{"kind": "jira", "ref": "JIRA-123", "project": "OPS"},
|
||||
):
|
||||
with self.subTest(legacy_source=legacy_source):
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["source"] = legacy_source
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
valid_ref = "feishu-base:sha256:" + "a" * 64
|
||||
valid = valid_knowledge_board()
|
||||
valid["tasks"][0]["source"] = {
|
||||
"kind": "feishu-base",
|
||||
"ref": valid_ref,
|
||||
"recordId": "recA",
|
||||
"updatedAt": "2026-08-01T12:00:00Z",
|
||||
}
|
||||
self.assert_board_accepted_in_all_modes(valid)
|
||||
|
||||
raw = copy.deepcopy(valid)
|
||||
raw["tasks"][0]["source"]["ref"] = "feishu-base:tenant:base-secret:recA"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
raw,
|
||||
"必须是不透明 feishu-base SHA-256 引用",
|
||||
)
|
||||
|
||||
duplicate = copy.deepcopy(valid)
|
||||
second = copy.deepcopy(duplicate["tasks"][0])
|
||||
second["id"] = "T-2"
|
||||
duplicate["tasks"].append(second)
|
||||
self.assert_board_rejected_in_all_modes(duplicate, "来源引用重复")
|
||||
|
||||
def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None:
|
||||
current = valid_knowledge_board()
|
||||
current["ackVersion"] = "0.10.0"
|
||||
@@ -281,6 +403,41 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
"delivery run 只能引用 verified 任务",
|
||||
)
|
||||
|
||||
def test_validation_ready_delivery_run_does_not_require_pull_request(self) -> None:
|
||||
board = valid_manual_routing_board()
|
||||
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||
board["tasks"][0]["status"] = "verified"
|
||||
board["deliveryRuns"] = [
|
||||
{
|
||||
"id": "DR-local-1",
|
||||
"profile": "local-validation",
|
||||
"taskIds": ["T-1"],
|
||||
"status": "validation_ready",
|
||||
"sourceRevision": "a" * 64,
|
||||
"configRevision": "b" * 64,
|
||||
"pullRequest": None,
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "local-service",
|
||||
"type": "file",
|
||||
"reference": "music-pilot",
|
||||
"digest": "sha256:" + "c" * 64,
|
||||
}
|
||||
],
|
||||
"deployments": [
|
||||
{
|
||||
"environment": "local-8080",
|
||||
"result": "succeeded",
|
||||
"evidence": "HTTP 200 and preflight passed",
|
||||
}
|
||||
],
|
||||
"evidence": ["http://127.0.0.1:8080 ready for user validation"],
|
||||
"updatedAt": "2026-08-03T23:10:00+08:00",
|
||||
}
|
||||
]
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None:
|
||||
board = valid_manual_routing_board()
|
||||
board["deliveryRuns"] = []
|
||||
@@ -989,6 +1146,62 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
"dispatch.rounds: round 必须从 1 连续递增且不重复",
|
||||
)
|
||||
|
||||
def test_environment_incidents_do_not_consume_round_budget(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["status"] = "fixed_by_dev"
|
||||
board["tasks"][0]["dispatch"] = {
|
||||
"rounds": [],
|
||||
"environmentIncidents": [
|
||||
{
|
||||
"id": f"T-1-ENV-{index}",
|
||||
"attemptId": f"T-1-A{index}",
|
||||
"role": "test",
|
||||
"phase": "browser",
|
||||
"status": "resolved",
|
||||
"summary": "browser runtime was unavailable",
|
||||
"evidence": "browser executable lookup returned no result",
|
||||
"impact": "interactive acceptance signals were not evaluated",
|
||||
"recoveryAction": "launch a network-enabled fresh Test worker",
|
||||
"userAction": "none; Coordinator continues the recovery",
|
||||
"reportedAt": "2026-08-03T20:00:00+08:00",
|
||||
"resolvedAt": "2026-08-03T20:05:00+08:00",
|
||||
}
|
||||
for index in range(1, 5)
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
def test_environment_incidents_require_actionable_reporting(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["dispatch"] = {
|
||||
"rounds": [],
|
||||
"environmentIncidents": [
|
||||
{
|
||||
"id": "WRONG-ENV-9",
|
||||
"role": "observer",
|
||||
"phase": "unknown",
|
||||
"status": "resolved",
|
||||
"summary": "",
|
||||
"evidence": "",
|
||||
"impact": "",
|
||||
"recoveryAction": "",
|
||||
"userAction": "",
|
||||
"reportedAt": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"environmentIncidents[0].id: 应为 T-1-ENV-1",
|
||||
"environmentIncidents[0].role: 必须是 coordinator/developer/test",
|
||||
"environmentIncidents[0].phase: 非法环境阶段",
|
||||
"environmentIncidents[0].summary: 必须是非空字符串",
|
||||
"environmentIncidents[0].userAction: 必须是非空字符串",
|
||||
"environmentIncidents[0]: resolved 必须填写 resolvedAt",
|
||||
)
|
||||
|
||||
def test_leftover_reason_must_be_nonempty_string_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
|
||||
@@ -602,6 +602,32 @@ class ReceiptValidationTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_receipt_accepts_project_and_board_bound_launch_fingerprint(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
receipt = valid_receipt(routing)
|
||||
receipt["projectRoot"] = "/repo/demo"
|
||||
receipt["boardHash"] = worker_profiles.canonical_sha256({"tasks": []})
|
||||
receipt["launchFingerprint"] = worker_profiles.canonical_sha256({
|
||||
"protocolVersion": 1,
|
||||
"backend": "orca",
|
||||
"projectRoot": receipt["projectRoot"],
|
||||
"boardHash": receipt["boardHash"],
|
||||
"profileId": receipt["profileId"],
|
||||
"profileHash": receipt["profileHash"],
|
||||
"createdFor": receipt["createdFor"],
|
||||
"worktree": receipt["worktree"],
|
||||
"requested": receipt["requested"],
|
||||
"slot": receipt["slot"],
|
||||
})
|
||||
receipt["receiptHash"] = worker_profiles.receipt_hash(receipt)
|
||||
|
||||
self.assertEqual(
|
||||
worker_profiles.validate_worker_receipt(
|
||||
receipt, orchestration=routing, task_ids={"TASK-001"},
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_receipt_slot_is_bounded_and_bound_into_launch_fingerprint(self) -> None:
|
||||
routing = valid_orchestration()
|
||||
invalid = valid_receipt(routing)
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ORC_DIR = REPO_ROOT / "skills" / "orc"
|
||||
SCRIPT = ORC_DIR / "scripts" / "resolve_profile.py"
|
||||
CONFIG = ORC_DIR / "config.yaml"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("orc_resolve_profile", SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
orc_profiles = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(orc_profiles)
|
||||
|
||||
|
||||
class OrcSkillTests(unittest.TestCase):
|
||||
def test_orc_is_explicit_and_routes_to_independent_skills(self) -> None:
|
||||
skill = (ORC_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||
routing = (ORC_DIR / "references" / "routing.md").read_text(encoding="utf-8")
|
||||
metadata = (ORC_DIR / "agents" / "openai.yaml").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("allow_implicit_invocation: false", metadata)
|
||||
self.assertIn("low", skill)
|
||||
self.assertIn("mid", skill)
|
||||
self.assertIn("high", skill)
|
||||
self.assertIn("`codex` 和 `cursor-agent`", skill)
|
||||
self.assertIn("<orc-skill-dir>/config.yaml", skill)
|
||||
self.assertIn("不得在项目中创建 `docs/orc/config.yaml`", skill)
|
||||
self.assertIn("薄路由器", skill)
|
||||
self.assertIn("不做领域判断", skill)
|
||||
self.assertIn("当前 shell", skill)
|
||||
self.assertNotIn("用户未指定档位时采用以下判断", skill)
|
||||
self.assertNotIn("若该档位不足以安全完成", skill)
|
||||
for child in ("$ack", "$manage-release", "$deb-publisher", "$publish-docker-image"):
|
||||
self.assertIn(child, routing)
|
||||
|
||||
def test_children_do_not_reference_orc(self) -> None:
|
||||
for child in ("ack", "manage-release", "deb-publisher", "publish-docker-image"):
|
||||
for path in (REPO_ROOT / "skills" / child).rglob("*"):
|
||||
if not path.is_file() or "__pycache__" in path.parts:
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
self.assertNotIn("$orc", content, str(path))
|
||||
self.assertNotRegex(
|
||||
content,
|
||||
r"(?<![A-Za-z0-9_-])/orc(?![A-Za-z0-9_-])",
|
||||
str(path),
|
||||
)
|
||||
self.assertNotRegex(content, r"\bORC\b", str(path))
|
||||
self.assertNotIn("skills/orc", content, str(path))
|
||||
|
||||
def test_routing_pins_normal_pr_and_unsupported_release_gate(self) -> None:
|
||||
routing = (ORC_DIR / "references" / "routing.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("普通非发布 PR/MR", routing)
|
||||
self.assertIn("最多到", routing)
|
||||
self.assertIn("review_ready", routing)
|
||||
self.assertIn("当前 task 粒度无法安全表达", routing)
|
||||
self.assertRegex(
|
||||
routing,
|
||||
r"不判断版本号、\s*实现方案、发布风险或产物策略",
|
||||
)
|
||||
|
||||
def test_shared_config_and_cli_validate(self) -> None:
|
||||
config = orc_profiles.load_config(CONFIG)
|
||||
self.assertEqual(config["version"], 2)
|
||||
self.assertEqual(config["cliPolicy"], "current-host")
|
||||
self.assertEqual(set(config["profiles"]), {"codex", "cursor-agent"})
|
||||
for cli in ("codex", "cursor-agent"):
|
||||
self.assertEqual(set(config["profiles"][cli]), {"low", "mid", "high"})
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "validate"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertIn('"ok": true', completed.stdout)
|
||||
|
||||
project_config_argument = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "validate", str(CONFIG)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(project_config_argument.returncode, 2)
|
||||
|
||||
clean_python = subprocess.run(
|
||||
[sys.executable, "-I", "-S", str(SCRIPT), "validate"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(clean_python.returncode, 0, clean_python.stderr)
|
||||
|
||||
def test_level_precedence_is_stage_then_global_then_config(self) -> None:
|
||||
config = orc_profiles.load_config(CONFIG)
|
||||
stage = orc_profiles.resolve_profile(
|
||||
config,
|
||||
stage="docker",
|
||||
host_cli="codex",
|
||||
global_level="mid",
|
||||
stage_level="high",
|
||||
)
|
||||
global_choice = orc_profiles.resolve_profile(
|
||||
config, stage="docker", host_cli="codex", global_level="mid"
|
||||
)
|
||||
configured = orc_profiles.resolve_profile(
|
||||
config, stage="docker", host_cli="codex"
|
||||
)
|
||||
fallback_config = copy.deepcopy(config)
|
||||
del fallback_config["stageDefaults"]["docker"]
|
||||
fallback = orc_profiles.resolve_profile(
|
||||
fallback_config, stage="docker", host_cli="codex"
|
||||
)
|
||||
|
||||
self.assertEqual((stage["level"], stage["selectionSource"]), ("high", "request.stage"))
|
||||
self.assertEqual((global_choice["level"], global_choice["selectionSource"]), ("mid", "request.global"))
|
||||
self.assertEqual((configured["level"], configured["selectionSource"]), ("low", "config.stageDefaults.docker"))
|
||||
self.assertEqual((fallback["level"], fallback["selectionSource"]), ("mid", "config.defaultLevel"))
|
||||
self.assertEqual(configured["cli"], "codex")
|
||||
self.assertEqual(configured["cliSelectionSource"], "runtime.host")
|
||||
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "host CLI is required"):
|
||||
orc_profiles.resolve_profile(config, stage="docker")
|
||||
|
||||
def test_resolver_builds_fixed_worker_args_without_free_command_fields(self) -> None:
|
||||
config = orc_profiles.load_config(CONFIG)
|
||||
plan = orc_profiles.resolve_profile(
|
||||
config, stage="release", host_cli="codex"
|
||||
)
|
||||
|
||||
self.assertEqual(plan["workerArgs"][:2], ["--model", "gpt-5.6-terra"])
|
||||
self.assertIn("--strict-config", plan["workerArgs"])
|
||||
approval_index = plan["workerArgs"].index("--ask-for-approval")
|
||||
self.assertEqual(plan["workerArgs"][approval_index + 1], "on-request")
|
||||
self.assertIn('approvals_reviewer="auto_review"', plan["workerArgs"])
|
||||
self.assertNotIn(
|
||||
"sandbox_workspace_write.network_access=true",
|
||||
plan["workerArgs"],
|
||||
)
|
||||
self.assertNotIn("danger-full-access", " ".join(plan["workerArgs"]))
|
||||
self.assertNotIn("env", plan["profile"])
|
||||
self.assertNotIn("command", plan["profile"])
|
||||
|
||||
cursor = orc_profiles.resolve_profile(
|
||||
config,
|
||||
stage="release",
|
||||
host_cli="cursor-agent",
|
||||
stage_level="low",
|
||||
)
|
||||
self.assertEqual(cursor["cli"], "cursor-agent")
|
||||
self.assertEqual(cursor["cliSelectionSource"], "runtime.host")
|
||||
self.assertEqual(cursor["modelAuth"], "cursor-login")
|
||||
self.assertEqual(
|
||||
cursor["workerArgs"],
|
||||
[
|
||||
"--model",
|
||||
"auto",
|
||||
"--auto-review",
|
||||
"--sandbox",
|
||||
"enabled",
|
||||
],
|
||||
)
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "not valid for cursor-agent"):
|
||||
orc_profiles.resolve_profile(
|
||||
config,
|
||||
stage="release",
|
||||
host_cli="cursor-agent",
|
||||
model_auth="openai",
|
||||
)
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "not valid for codex"):
|
||||
orc_profiles.resolve_profile(
|
||||
config,
|
||||
stage="release",
|
||||
host_cli="codex",
|
||||
model_auth="cursor-api-key",
|
||||
)
|
||||
|
||||
def test_worker_environment_selects_one_model_and_remote_auth(self) -> None:
|
||||
ambient = {
|
||||
"OPENAI_API_KEY": "openai-secret",
|
||||
"AZURE_OPENAI_API_KEY": "azure-secret",
|
||||
"GITHUB_TOKEN": "github-secret",
|
||||
"GITLAB_TOKEN": "gitlab-secret",
|
||||
"GITEA_TOKEN": "gitea-secret",
|
||||
"FORGEJO_TOKEN": "forgejo-secret",
|
||||
"DEB_TOKEN": "deb-secret",
|
||||
"SSH_AUTH_SOCK": "/tmp/agent.sock",
|
||||
"HTTPS_PROXY": "https://user:secret@proxy.example",
|
||||
}
|
||||
with mock.patch.dict(os.environ, ambient, clear=True):
|
||||
environment = orc_profiles.worker_environment(
|
||||
"release",
|
||||
model_auth="openai",
|
||||
remote_auth="forgejo-token",
|
||||
)
|
||||
|
||||
self.assertEqual(environment["OPENAI_API_KEY"], "openai-secret")
|
||||
self.assertEqual(environment["FORGEJO_TOKEN"], "forgejo-secret")
|
||||
for rejected in (
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GITLAB_TOKEN",
|
||||
"GITEA_TOKEN",
|
||||
"DEB_TOKEN",
|
||||
"SSH_AUTH_SOCK",
|
||||
"HTTPS_PROXY",
|
||||
):
|
||||
self.assertNotIn(rejected, environment)
|
||||
|
||||
cursor_ambient = {
|
||||
"CURSOR_API_KEY": "cursor-secret",
|
||||
"OPENAI_API_KEY": "openai-secret",
|
||||
}
|
||||
with mock.patch.dict(os.environ, cursor_ambient, clear=True):
|
||||
cursor_environment = orc_profiles.worker_environment(
|
||||
"release",
|
||||
model_auth="cursor-api-key",
|
||||
remote_auth="none",
|
||||
)
|
||||
self.assertEqual(cursor_environment["CURSOR_API_KEY"], "cursor-secret")
|
||||
self.assertNotIn("OPENAI_API_KEY", cursor_environment)
|
||||
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "deb remote auth"):
|
||||
orc_profiles.worker_environment(
|
||||
"deb",
|
||||
model_auth="codex-login",
|
||||
remote_auth="forgejo-token",
|
||||
)
|
||||
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"selected authentication variable is unavailable",
|
||||
):
|
||||
orc_profiles.worker_environment(
|
||||
"release",
|
||||
model_auth="openai",
|
||||
remote_auth="none",
|
||||
)
|
||||
|
||||
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": ""}, clear=True):
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"selected authentication variable is unavailable",
|
||||
):
|
||||
orc_profiles.worker_environment(
|
||||
"release",
|
||||
model_auth="openai",
|
||||
remote_auth="none",
|
||||
)
|
||||
|
||||
def test_release_remote_requires_one_identical_fetch_and_push_url(self) -> None:
|
||||
git = shutil.which("git")
|
||||
self.assertIsNotNone(git)
|
||||
assert git is not None
|
||||
git_path = Path(git).resolve()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
subprocess.run(
|
||||
[str(git_path), "init", "-q", str(root)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
remote = "https://git.yumee.top/laily/musicpilot.git"
|
||||
subprocess.run(
|
||||
[str(git_path), "-C", str(root), "remote", "add", "origin", remote],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
with mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_executable",
|
||||
return_value=git_path,
|
||||
):
|
||||
facts = orc_profiles.resolve_release_remote(root)
|
||||
|
||||
self.assertEqual(facts["fetchUrl"], remote)
|
||||
self.assertEqual(facts["pushUrl"], remote)
|
||||
self.assertEqual(facts["host"], "git.yumee.top")
|
||||
self.assertEqual(facts["networkHosts"], ["git.yumee.top"])
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
str(git_path),
|
||||
"-C",
|
||||
str(root),
|
||||
"config",
|
||||
"remote.origin.pushurl",
|
||||
"https://git.example.invalid/other/repo.git",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
with mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_executable",
|
||||
return_value=git_path,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"fetch and push URLs must match",
|
||||
):
|
||||
orc_profiles.resolve_release_remote(root)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
str(git_path),
|
||||
"-C",
|
||||
str(root),
|
||||
"config",
|
||||
"--add",
|
||||
"remote.origin.pushurl",
|
||||
"https://git.example.invalid/second/repo.git",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
with mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_executable",
|
||||
return_value=git_path,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"exactly one push URL",
|
||||
):
|
||||
orc_profiles.resolve_release_remote(root)
|
||||
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "safe remote URL"):
|
||||
orc_profiles._remote_url_facts(
|
||||
"https://git.example.invalid:notaport/repo.git",
|
||||
"test remote",
|
||||
)
|
||||
scp = orc_profiles._remote_url_facts(
|
||||
"git@git.yumee.top:laily/musicpilot.git",
|
||||
"scp remote",
|
||||
)
|
||||
ssh = orc_profiles._remote_url_facts(
|
||||
"ssh://git@git.yumee.top/laily/musicpilot.git",
|
||||
"ssh remote",
|
||||
)
|
||||
self.assertEqual(scp["canonical"], ssh["canonical"])
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "local host"):
|
||||
orc_profiles._remote_url_facts(
|
||||
"https://localhost/laily/musicpilot.git",
|
||||
"local remote",
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
orc_profiles,
|
||||
"_origin_urls",
|
||||
side_effect=[
|
||||
["https://github.com/example/project.git"],
|
||||
["https://github.com/example/project.git"],
|
||||
],
|
||||
), mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_executable",
|
||||
return_value=git_path,
|
||||
):
|
||||
github = orc_profiles.resolve_release_remote(Path("/tmp/project"))
|
||||
self.assertEqual(
|
||||
github["networkHosts"],
|
||||
["github.com", "api.github.com", "uploads.github.com"],
|
||||
)
|
||||
|
||||
def test_ssh_auth_requires_a_trusted_user_socket(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
socket_path = root / "agent.sock"
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as agent:
|
||||
agent.bind(str(socket_path))
|
||||
socket_path.chmod(0o600)
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"SSH_AUTH_SOCK": str(socket_path)},
|
||||
clear=True,
|
||||
):
|
||||
environment = orc_profiles.worker_environment(
|
||||
"release",
|
||||
model_auth="codex-login",
|
||||
remote_auth="ssh-agent",
|
||||
)
|
||||
self.assertEqual(environment["SSH_AUTH_SOCK"], str(socket_path))
|
||||
|
||||
regular_file = root / "not-a-socket"
|
||||
regular_file.write_text("not a socket", encoding="utf-8")
|
||||
regular_file.chmod(0o600)
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"SSH_AUTH_SOCK": str(regular_file)},
|
||||
clear=True,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"not a trusted user socket",
|
||||
):
|
||||
orc_profiles.worker_environment(
|
||||
"release",
|
||||
model_auth="codex-login",
|
||||
remote_auth="ssh-agent",
|
||||
)
|
||||
|
||||
def test_trusted_orca_ignores_ambient_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
trusted = root / "trusted"
|
||||
hostile = root / "hostile"
|
||||
trusted.mkdir()
|
||||
hostile.mkdir()
|
||||
trusted_orca = trusted / "orca"
|
||||
hostile_orca = hostile / "orca"
|
||||
for executable in (trusted_orca, hostile_orca):
|
||||
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
executable.chmod(0o700)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
orc_profiles,
|
||||
"trusted_path_entries",
|
||||
return_value=[trusted],
|
||||
),
|
||||
mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=True),
|
||||
):
|
||||
resolved = orc_profiles.resolve_trusted_executable("orca")
|
||||
|
||||
self.assertEqual(resolved, trusted_orca)
|
||||
|
||||
def test_config_rejects_unsafe_or_ambiguous_profiles(self) -> None:
|
||||
base = orc_profiles.load_config(CONFIG)
|
||||
|
||||
free_command = copy.deepcopy(base)
|
||||
free_command["profiles"]["codex"]["low"]["command"] = "codex --dangerously-bypass"
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "unknown fields"):
|
||||
orc_profiles.validate_config(free_command)
|
||||
|
||||
full_access = copy.deepcopy(base)
|
||||
full_access["profiles"]["codex"]["high"]["permissionMode"] = "danger-full-access"
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v2"):
|
||||
orc_profiles.validate_config(full_access)
|
||||
|
||||
read_only = copy.deepcopy(base)
|
||||
read_only["profiles"]["codex"]["low"]["permissionMode"] = "read-only"
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v2"):
|
||||
orc_profiles.validate_config(read_only)
|
||||
|
||||
missing_level = copy.deepcopy(base)
|
||||
del missing_level["profiles"]["cursor-agent"]["mid"]
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "missing fields: mid"):
|
||||
orc_profiles.validate_config(missing_level)
|
||||
|
||||
option_model = copy.deepcopy(base)
|
||||
option_model["profiles"]["codex"]["low"]["model"] = "--model"
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "safe exact model ID"):
|
||||
orc_profiles.validate_config(option_model)
|
||||
|
||||
list_policy = copy.deepcopy(base)
|
||||
list_policy["profiles"]["codex"]["low"]["approvalPolicy"] = ["never"]
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "approvalPolicy"):
|
||||
orc_profiles.validate_config(list_policy)
|
||||
|
||||
cursor_effort = copy.deepcopy(base)
|
||||
cursor_effort["profiles"]["cursor-agent"]["low"]["reasoningEffort"] = "low"
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "Cursor requires null"):
|
||||
orc_profiles.validate_config(cursor_effort)
|
||||
|
||||
mislabeled_cursor = copy.deepcopy(base)
|
||||
mislabeled_cursor["profiles"]["cursor-agent"]["low"]["model"] = (
|
||||
"gpt-5.6-sol-high"
|
||||
)
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "encode the low"):
|
||||
orc_profiles.validate_config(mislabeled_cursor)
|
||||
|
||||
project_specific = copy.deepcopy(base)
|
||||
project_specific["worktreePolicy"] = ["."]
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"registered-same-repository",
|
||||
):
|
||||
orc_profiles.validate_config(project_specific)
|
||||
|
||||
default_cli = copy.deepcopy(base)
|
||||
default_cli["cliPolicy"] = "default-codex"
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "current-host"):
|
||||
orc_profiles.validate_config(default_cli)
|
||||
|
||||
def test_config_reader_rejects_symlinks_and_redacts_parser_input(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
real = root / "real.yaml"
|
||||
real.write_text(CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
linked = root / "linked.yaml"
|
||||
linked.symlink_to(real)
|
||||
with self.assertRaisesRegex(orc_profiles.ConfigError, "safely read"):
|
||||
orc_profiles.load_config(linked)
|
||||
|
||||
malformed = root / "malformed.yaml"
|
||||
secret_marker = "PRIVATE_MATERIAL_MUST_NOT_APPEAR"
|
||||
malformed.write_text(f"{secret_marker}: [unterminated\n", encoding="utf-8")
|
||||
with self.assertRaises(orc_profiles.ConfigError) as error:
|
||||
orc_profiles.load_config(malformed)
|
||||
self.assertNotIn(secret_marker, str(error.exception))
|
||||
|
||||
def test_bound_plan_enforces_registered_same_repository_worktree(self) -> None:
|
||||
git = shutil.which("git")
|
||||
self.assertIsNotNone(git)
|
||||
assert git is not None
|
||||
git_path = Path(git).resolve()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="orc shell $(id) ' ") as temporary:
|
||||
temporary_root = Path(temporary).resolve()
|
||||
root = temporary_root / "project"
|
||||
root.mkdir()
|
||||
subprocess.run(
|
||||
[str(git_path), "init", "-q", str(root)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
str(git_path),
|
||||
"-C",
|
||||
str(root),
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://git.yumee.top/laily/project.git",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
fake_codex = root / "trusted-codex" / "codex"
|
||||
fake_codex.parent.mkdir()
|
||||
fake_codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
fake_codex.chmod(0o700)
|
||||
fake_cursor = root / "trusted-cursor" / "cursor-agent"
|
||||
fake_cursor.parent.mkdir()
|
||||
fake_cursor.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
fake_cursor.chmod(0o700)
|
||||
fake_orca = root / "trusted-orca" / "orca"
|
||||
fake_orca.parent.mkdir()
|
||||
fake_orca.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
fake_orca.chmod(0o700)
|
||||
|
||||
def executable(name: str) -> Path:
|
||||
return {
|
||||
"git": git_path,
|
||||
"codex": fake_codex,
|
||||
"cursor-agent": fake_cursor,
|
||||
"orca": fake_orca,
|
||||
}[name]
|
||||
|
||||
def executable_facts(path: Path) -> dict[str, object]:
|
||||
return {
|
||||
"path": str(path),
|
||||
"device": 1,
|
||||
"inode": 2,
|
||||
"size": 3,
|
||||
"mtimeNs": 4,
|
||||
"version": f"{path.name}-test 1",
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_executable",
|
||||
side_effect=executable,
|
||||
),
|
||||
mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_python",
|
||||
return_value=Path(sys.executable).resolve(),
|
||||
),
|
||||
mock.patch.object(
|
||||
orc_profiles,
|
||||
"_executable_facts",
|
||||
side_effect=executable_facts,
|
||||
),
|
||||
):
|
||||
plan = orc_profiles.build_launch_plan(
|
||||
project_root=root,
|
||||
worktree=root,
|
||||
stage="code",
|
||||
host_cli="codex",
|
||||
stage_level="high",
|
||||
)
|
||||
|
||||
cursor_plan = orc_profiles.build_launch_plan(
|
||||
project_root=root,
|
||||
worktree=root,
|
||||
stage="release",
|
||||
host_cli="cursor-agent",
|
||||
stage_level="low",
|
||||
)
|
||||
|
||||
release_plan = orc_profiles.build_launch_plan(
|
||||
project_root=root,
|
||||
worktree=root,
|
||||
stage="release",
|
||||
host_cli="codex",
|
||||
stage_level="low",
|
||||
)
|
||||
|
||||
self.assertEqual(plan["argv"][0], str(fake_codex))
|
||||
self.assertEqual(plan["config"]["path"], str(CONFIG))
|
||||
self.assertEqual(cursor_plan["argv"][0], str(fake_cursor))
|
||||
self.assertEqual(cursor_plan["executable"]["path"], str(fake_cursor))
|
||||
self.assertEqual(cursor_plan["argv"][1:3], ["--model", "auto"])
|
||||
self.assertIn("--auto-review", cursor_plan["argv"])
|
||||
self.assertEqual(cursor_plan["modelAuth"], "cursor-login")
|
||||
self.assertEqual(cursor_plan["releaseRemote"]["host"], "git.yumee.top")
|
||||
self.assertIn("--host-cli", cursor_plan["launcherArgv"])
|
||||
release_args = release_plan["workerArgs"]
|
||||
self.assertIn("sandbox_workspace_write.network_access=true", release_args)
|
||||
self.assertIn("features.network_proxy.enabled=true", release_args)
|
||||
self.assertIn("features.network_proxy.allow_upstream_proxy=false", release_args)
|
||||
self.assertIn("features.network_proxy.unix_sockets={}", release_args)
|
||||
self.assertIn(
|
||||
'features.network_proxy.domains={ "git.yumee.top" = "allow" }',
|
||||
release_args,
|
||||
)
|
||||
release_approval = release_args.index("--ask-for-approval")
|
||||
self.assertEqual(release_args[release_approval + 1], "on-request")
|
||||
self.assertIn('approvals_reviewer="auto_review"', release_args)
|
||||
self.assertEqual(plan["terminalCreateArgv"][0], str(fake_orca))
|
||||
self.assertEqual(plan["worktree"], str(root))
|
||||
self.assertEqual(plan["selectionSource"], "request.stage")
|
||||
self.assertTrue(plan["launchFingerprint"].startswith("sha256:"))
|
||||
self.assertIn("_launch", plan["launcherArgv"])
|
||||
self.assertEqual(plan["launcherArgv"][1:3], ["-I", "-S"])
|
||||
self.assertIn("python", plan)
|
||||
self.assertEqual(plan["worktreeSelector"], f"path:{root}")
|
||||
self.assertNotIn("PRIVATE", json.dumps(plan))
|
||||
self.assertEqual(shlex.split(plan["terminalCommand"]), plan["launcherArgv"])
|
||||
self.assertEqual(
|
||||
shlex.split(plan["terminalCreateShellCommand"]),
|
||||
plan["terminalCreateArgv"],
|
||||
)
|
||||
command_index = plan["terminalCreateArgv"].index("--command")
|
||||
self.assertEqual(
|
||||
plan["terminalCreateArgv"][command_index + 1],
|
||||
plan["terminalCommand"],
|
||||
)
|
||||
|
||||
outside = temporary_root / "outside"
|
||||
outside.mkdir()
|
||||
subprocess.run(
|
||||
[str(git_path), "init", "-q", str(outside)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
with mock.patch.object(
|
||||
orc_profiles,
|
||||
"resolve_trusted_executable",
|
||||
side_effect=executable,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
orc_profiles.ConfigError,
|
||||
"not registered in the project repository",
|
||||
):
|
||||
orc_profiles.validate_worktree(
|
||||
orc_profiles.load_config(CONFIG),
|
||||
project_root_value=root,
|
||||
worktree_value=outside,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user