feat(插件): 发布意图提单 V2

This commit is contained in:
cc
2026-08-27 12:03:08 +08:00
parent 72e5396e6d
commit 758aa59470
10 changed files with 1107 additions and 215 deletions
+4 -3
View File
@@ -2,7 +2,7 @@
EasyAI 团队的 Codex 插件市场。目前包含: EasyAI 团队的 Codex 插件市场。目前包含:
- `easyai-product-intake`(界面名称:**EasyAI 提需求/报 Bug**):引导产品经理补齐需求或 Bug 信息,在明确确认后提交到飞书多维表格。 - `easyai-product-intake`(界面名称:**EasyAI 提需求/报 Bug**):提取负责人要解决的问题与期望结果,在明确确认后提交到飞书多维表格。
仓库公开可读,产品经理无需 Gitea 账号即可查看或下载。提交飞书任务仍然必须使用本人已授权的飞书身份;公开仓库不会授予任何飞书数据访问权限。 仓库公开可读,产品经理无需 Gitea 账号即可查看或下载。提交飞书任务仍然必须使用本人已授权的飞书身份;公开仓库不会授予任何飞书数据访问权限。
@@ -84,10 +84,11 @@ lark-cli config init --new
插件会: 插件会:
1. 判断是需求还是 Bug。 1. 判断是需求还是 Bug。
2. 每轮最多追问 3 个会影响执行或验收的问题 2. 只有产品行为存在实质歧义时才追问,每轮最多提出 1 个阻塞问题,并给出推荐选项与影响
3. 展示结构化预览。 3. 展示包含全部待提交字段的结构化预览。
4. 等待你明确回复“确认提交”。 4. 等待你明确回复“确认提交”。
5. 使用你的飞书身份创建记录,并返回记录链接和提交幂等键。 5. 使用你的飞书身份创建记录,并返回记录链接和提交幂等键。
6. 由当前自动化服务判断直接开始、继续澄清或转人工,并通过飞书卡片通知。
没有明确确认时,插件不会写入飞书。任务状态、执行阶段、代码分支、PR、部署和审计字段由中央流水线维护,产品经理无需编辑。 没有明确确认时,插件不会写入飞书。任务状态、执行阶段、代码分支、PR、部署和审计字段由中央流水线维护,产品经理无需编辑。
@@ -1,7 +1,7 @@
{ {
"name": "easyai-product-intake", "name": "easyai-product-intake",
"version": "0.1.0+codex.20260811043301", "version": "0.1.0+codex.20260827040247",
"description": "\u5e2e\u52a9 EasyAI \u4ea7\u54c1\u7ecf\u7406\u4ee5\u7edf\u4e00\u683c\u5f0f\u63d0\u4ea4\u9700\u6c42\u548c Bug \u5230\u98de\u4e66\u591a\u7ef4\u8868\u683c\u3002", "description": "\u63d0\u53d6 EasyAI \u8d1f\u8d23\u4eba\u7684\u539f\u59cb\u610f\u56fe\u4e0e\u671f\u671b\u7ed3\u679c\uff0c\u5b8c\u6574\u9884\u89c8\u5e76\u660e\u786e\u786e\u8ba4\u540e\u5b89\u5168\u63d0\u4ea4\u9700\u6c42\u6216 Bug \u5230\u98de\u4e66\u591a\u7ef4\u8868\u683c\u3002",
"author": { "author": {
"name": "EasyAI" "name": "EasyAI"
}, },
@@ -11,7 +11,7 @@
"interface": { "interface": {
"displayName": "EasyAI \u63d0\u9700\u6c42/\u62a5 Bug", "displayName": "EasyAI \u63d0\u9700\u6c42/\u62a5 Bug",
"shortDescription": "\u89c4\u8303\u6536\u96c6\u4ea7\u54c1\u9700\u6c42\u4e0e Bug \u5e76\u63d0\u4ea4\u5230\u98de\u4e66", "shortDescription": "\u89c4\u8303\u6536\u96c6\u4ea7\u54c1\u9700\u6c42\u4e0e Bug \u5e76\u63d0\u4ea4\u5230\u98de\u4e66",
"longDescription": "\u5f15\u5bfc\u4ea7\u54c1\u8865\u9f50\u5fc5\u586b\u4fe1\u606f\uff0c\u9884\u89c8\u786e\u8ba4\u540e\u4ec5\u5199\u5165\u98de\u4e66\u591a\u7ef4\u8868\u683c\u7684\u4ea7\u54c1\u5b57\u6bb5\u3002", "longDescription": "\u53ea\u6536\u96c6\u7c7b\u578b\u3001\u6807\u9898\u3001\u8981\u89e3\u51b3\u7684\u95ee\u9898\u4e0e\u671f\u671b\u7ed3\u679c\u7b49\u8d1f\u8d23\u4eba\u610f\u56fe\uff0c\u6280\u672f\u8303\u56f4\u4e0e\u9a8c\u6536\u8bbe\u8ba1\u7531\u540e\u7eed Codex \u8c03\u7814\u3002\u5b8c\u6574\u9884\u89c8\u5e76\u660e\u786e\u786e\u8ba4\u540e\u624d\u5199\u5165\u98de\u4e66\uff0c\u5e76\u9632\u6b62\u9644\u4ef6\u53d8\u66f4\u3001\u5e76\u53d1\u6216\u7ed3\u679c\u672a\u77e5\u5bfc\u81f4\u91cd\u590d\u63d0\u4ea4\u3002",
"developerName": "EasyAI", "developerName": "EasyAI",
"category": "Productivity", "category": "Productivity",
"capabilities": [ "capabilities": [
@@ -1,39 +1,43 @@
--- ---
name: easyai-submit-task name: easyai-submit-task
description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表格。当用户说“提需求”“新建需求”“报 Bug”“反馈问题”“登记产品任务”或要把产品想法提交给 Codex 处理时使用 description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表格。仅在用户明确要求“提需求”“新建需求”“报 Bug”“反馈问题”“登记/提交产品任务”时使用;单纯讨论、分析、润色产品想法或询问流程时不触发提交工作流
--- ---
# EasyAI 提需求/报 Bug # EasyAI 提需求/报 Bug
## 执行流程 ## 执行流程
1. 读取 [intake-rules.md](references/intake-rules.md) 判断类型和必填信息 1. 读取 [intake-rules.md](references/intake-rules.md) 判断是否已有类型、标题、要解决的问题/目标和期望结果
2. 检查用户已给出的信息。每轮最多追问 3 个对验收有实质影响的问题,不询问仓库、分支或技术方案 2. 只在产品行为存在实质歧义、无法形成这四项最小意图时追问。每轮最多问一个最关键的阻塞问题,并给出 2~3 个产品选项、推荐方案和各自影响。范围、仓库、分支、框架、实现与测试方式由后续 Codex 调研,不向负责人追问
3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs` 3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs`
4. 展示脚本返回的中文预览。必须等待用户明确说“确认提交”或等价表达。 4. 展示脚本返回的完整中文预览和提交幂等键。必须等待用户明确说“确认提交”或等价表达。
5. 确认后,将同一 JSON 通过 stdin 传给 `scripts/submit-intake.mjs --confirm <confirmation_token>` 5. 若用户在确认前修改任何字段,重新预览并使用新的 `confirmation_token`;旧确认令牌失效
6. 返回提交回执、幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成并通过飞书通知 6. 确认后运行 `scripts/submit-intake.mjs --confirm <confirmation_token>`;脚本读取已保存且绑定负责人身份的草稿,不接受模型在提交时悄悄改写内容
7. 返回提交回执、幂等键和处理状态。若返回 `unknown`,先按幂等键核对 Base;确认不存在后,只有在负责人再次明确同意时才加 `--retry-unknown` 重试。
## 命令 ## 命令
```bash 从本 `SKILL.md` 所在目录解析脚本绝对路径,但保持进程工作目录为用户当前工作目录,以便附件相对路径正确解析。优先启动 Node 进程后通过执行工具的 stdin 通道传入原始 JSON;不要把用户文本插值进 shell 命令。
printf '%s' '<json>' | node scripts/prepare-intake.mjs
printf '%s' '<json>' | node scripts/submit-intake.mjs --confirm '<confirmation_token>' ```text
node <skill-root>/scripts/prepare-intake.mjs
node <skill-root>/scripts/submit-intake.mjs --confirm <confirmation_token>
``` ```
`submit-intake.mjs` 使用当前电脑已授权的飞书用户身份提交企业内受控表单,不要要求用户提供 token、Cookie 或密码。 `submit-intake.mjs` 使用当前电脑已授权的飞书用户身份提交企业内受控表单,不要要求用户提供 token、Cookie 或密码。
## 安全边界 ## 安全边界
- 只能通过受控表单写入 [base-schema.md](references/base-schema.md) 列出的产品字段 - 产品字段只能通过受控表单写入。表单成功后,脚本只按返回的 `record_id` 补写附件内容已纳入的 `原始意图指纹`,不修改任何产品文本
- 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。 - 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。
- 不经用户确认不得运行提交脚本。 - 不经用户确认不得运行提交脚本。
- 范围和验收提示都是可选信息;不得因为缺少技术范围、仓库、风险分析或完整验收设计而阻止登记。
- 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。 - 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。
- 提交失败时保留原输入并报告结构化错误,不得改写产品意图后自动重试 - 提交失败时保留原输入并报告结构化错误,不得改写产品意图或盲目重试。附件内容变化会使旧确认令牌失效
## 返回规则 ## 返回规则
- 信息不完整:只返回下一组最多 3 个问题 - 信息不完整:只返回一个最关键的产品问题,包含 2~3 个选项、推荐方案和影响
- 待确认:展示类型、标题、背景、验收标准、优先级和附件 - 待确认:展示脚本 `preview` 返回的全部字段、提交幂等键,并提示“回复确认提交后才会写入飞书”
- 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”。 - 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”;提醒用户留意后续自动开始、澄清或转人工卡片
- 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。 - 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。
@@ -1,4 +1,4 @@
interface: interface:
display_name: "EasyAI 提需求/报 Bug" display_name: "EasyAI 提需求/报 Bug"
short_description: "规范收集产品需求 Bug 并安全提交到飞书多维表格" short_description: "提取负责人原始意图,确认后安全提交需求 Bug"
default_prompt: "Use $easyai-submit-task to submit a product requirement or bug to the EasyAI Feishu Base." default_prompt: "使用 $easyai-submit-task 提取我的目标和期望结果,完整预览并在我确认后提交一条 EasyAI 产品需求或 Bug。"
@@ -1,28 +1,27 @@
# 飞书 Base 字段契约 # 飞书 Base V2 提单契约
- Base Token: `Ed21b9VwNaZfiesxWgDc67wXnoc` 目标 Base 与表单由组织配置固定;脚本每次提交前读取真实表单详情,校验字段类型、必填配置和选项,不在 Skill 文档中复制一套可漂移的字段定义。
- 数据表: `tblelMMlOif1YHPD`(需求与缺陷)
- 受控表单: `产品需求与 Bug 提交`
- 表单提交范围: 仅企业内获得链接的用户
允许提交的字段 允许提交的 JSON
| JSON 属性 | Base 字段 | | | JSON 属性 | Base 字段 | 要求 |
|---|---|---| | --- | --- | --- |
| `type` | 类型 | `需求` / `Bug` | | `type` | 类型 | 必填;`需求` / `Bug` |
| `title` | 标题 | 非空文本 | | `title` | 标题 | 必填非空文本 |
| `background` | 需求背景与目标 | 非空文本 | | `background` / `problem_or_goal` | 需求背景与目标 | 必填非空文本 |
| `acceptance_criteria` | 验收标准 | 非空字符串数组 | | `expected_result` / `expected_outcome` | 期望结果 | 必填非空文本 |
| `priority` | 优先级 | `P0` / `P1` / `P2` / `P3` | | `acceptance_criteria` | 验收标准 | 可选字符串数组,仅作为验收提示 |
| `environment` | 发生环境 | Bug 必填 | | `priority` | 优先级 | 可选;`P0` / `P1` / `P2` / `P3`,默认 `P2` |
| `reproduction_steps` | 复现步骤 | Bug 必填字符串数组 | | `environment` | 发生环境 | Bug 可选 |
| `actual_result` | 实际结果 | Bug 必填 | | `reproduction_steps` | 复现步骤 | Bug 可选字符串数组 |
| `expected_result` | 期望结果 | Bug 必填 | | `actual_result` | 实际结果 | Bug 可选 |
| `impact_scope` | 影响范围 | `前端` / `后端` / `全栈` / `部署` 数组,可选 | | `impact_scope` | 影响范围 | 可选提示数组;`前端` / `后端` / `全栈` / `部署` |
| `constraints` | 约束与非目标 | 可选字符串数组,以“约束:”前缀保存 |
| `non_goals` | 约束与非目标 | 可选字符串数组,以“非目标:”前缀保存 |
| `desired_at` | 期望完成时间 | `YYYY-MM-DD HH:mm:ss`,可选 | | `desired_at` | 期望完成时间 | `YYYY-MM-DD HH:mm:ss`,可选 |
| `references` | 参考资料 | 可选文本 | | `references` | 参考资料 | 可选文本 |
| `attachments` | 附件 | 当前目录内相对路径数组 | | `attachments` | 附件 | 当前目录内相对路径数组 |
脚本通过受控表单自动写入但不接受模型指定:`产品负责人``提交幂等键``提交来源` 脚本自动注入且不接受模型指定:`产品负责人``提交幂等键``提交来源`附件路径、大小与 SHA256 一起进入确认摘要;表单成功后同一内容摘要会按返回的记录编号固化到 `原始意图指纹`。该补写失败时回执进入 `unknown`,下次先按幂等键找到已有记录并完成指纹固化,不会重复提交表单。
任务编号由中央调度器生成;其他系统字段不在受控表单中,全部禁止写入 受飞书现有表单主字段约束,`任务编号(系统自动生成,请勿填写)` 仍可能作为可选问题存在。V2 插件不得展示或提交它,任务编号由中央调度器生成;任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段都不属于提单插件
@@ -1,23 +1,40 @@
# 需求与 Bug 收集规则 # 需求与 Bug 收集规则
## 共同必填 ## 最小登记契约
- 类型、标题、背景与目标、可执行的验收标准、优先级 - 必填:类型、标题、背景与目标、期望结果
- 验收标准要描述可观察结果,避免“优化一下”“体验更好”等无法判定的表达 - 优先级默认 `P2`,只有负责人明确表达紧急程度时才调整
- 验收提示、范围提示、约束、非目标、期望时间、参考资料和附件都是可选信息。
- 范围提示可以是 `前端``后端``全栈``部署`;只填 `部署` 也允许登记。`全栈` 不与 `前端``后端` 同时选择。
- 产品负责人由当前飞书授权身份自动填入,不询问 open_id。 - 产品负责人由当前飞书授权身份自动填入,不询问 open_id。
## Bug 额外必填 ## 优先级
- 发生环境、复现步骤、实际结果、期望结果 - `P0`:正在发生的严重故障、安全事件或数据损失;不等同于自动紧急发布,服务仍会要求授权人员确认原因
- 有截图、视频或日志时作为附件;无法提供时明确记录“暂无证据”,不伪造证据 - `P1`:阻塞关键业务或近期交付,需优先处理
- `P2`:正常排期的需求或缺陷,默认选择。
- `P3`:低影响改进、体验优化或技术债。
## 追问优先级 ## Bug 可选补充
1. 先问会影响是否可执行和可验收的信息 - 发生环境、复现步骤、实际结果
2. 再问用户范围、兼容性或时间限制 - 有截图、视频或日志时作为附件;无法提供时不伪造证据
3. 不要让产品选择仓库、技术框架、实现类或数据库结构 - 缺少这些信息仍可登记。后续 Codex 先在代码、测试和环境中定位;只有产品现象或期望行为无法确定时才询问负责人
## 首个真实 Canary 条件 ## 追问协议
1. 只问会改变产品行为、目标或期望结果且确实阻塞登记的信息。
2. 每轮最多一个问题,提供 2~3 个选项、推荐项和选择影响。
3. 不问仓库、前后端归属、技术框架、实现类、数据库结构或测试方式;这些由 Codex 调研。
## 服务分流提示
- 最小契约成立:服务登记后由 Codex 调研代码并形成派生验收计划。
- 产品行为存在歧义:服务通过带问题版本、状态版本和意图版本校验的飞书卡片继续澄清。
- 只有技术信息不确定:Codex 自行搜索代码、测试和环境,不询问负责人。
- 涉及鉴权/权限、支付、数据库迁移、生产基础设施、密钥或破坏性变更:如实记录负责人目标,不替负责人缩小或批准范围;后续服务按风险策略处理。
## 首个真实 Canary 条件(仅当用户明确说是 Canary 时应用)
- 范围清晰、一天内可完成、验收结果确定、可回滚。 - 范围清晰、一天内可完成、验收结果确定、可回滚。
- 不涉及支付、权限、数据迁移、数据库结构或破坏性操作。 - 不涉及支付、权限、数据迁移、数据库结构或破坏性操作。
@@ -1,108 +1,203 @@
import { createHash } from 'node:crypto'; import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { readFile } from 'node:fs/promises'; import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
const allowedKeys = new Set([ const allowedKeys = new Set([
'type', "type",
'title', "title",
'background', "background",
'acceptance_criteria', "problem_or_goal",
'priority', "acceptance_criteria",
'environment', "priority",
'reproduction_steps', "environment",
'actual_result', "reproduction_steps",
'expected_result', "actual_result",
'impact_scope', "expected_result",
'desired_at', "expected_outcome",
'references', "impact_scope",
'attachments', "constraints",
"non_goals",
"desired_at",
"references",
"attachments",
]); ]);
export async function readStdin() { export async function readStdin() {
let input = ''; let input = "";
process.stdin.setEncoding('utf8'); process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) input += chunk; for await (const chunk of process.stdin) input += chunk;
if (!input.trim()) throw new Error('请通过 stdin 传入 JSON'); if (!input.trim()) throw new Error("请通过 stdin 传入 JSON");
return JSON.parse(input); return JSON.parse(input);
} }
/** Normalize the public V2 draft contract without asking for technical design. */
export function normalize(input) { export function normalize(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('输入必须是 JSON 对象'); if (!input || typeof input !== "object" || Array.isArray(input))
throw new Error("输入必须是 JSON 对象");
const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key)); const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key));
if (unknown.length) throw new Error(`存在禁止或未知字段: ${unknown.join(', ')}`); if (unknown.length)
throw new Error(`存在禁止或未知字段: ${unknown.join(", ")}`);
const background = input.problem_or_goal ?? input.background;
const expectedResult = input.expected_outcome ?? input.expected_result;
const value = { const value = {
type: requiredEnum(input.type, '类型', ['需求', 'Bug']), type: requiredEnum(input.type, "类型", ["需求", "Bug"]),
title: requiredText(input.title, '标题'), title: requiredText(input.title, "标题"),
background: requiredText(input.background, '背景与目标'), background: requiredText(background, "背景与目标"),
acceptance_criteria: requiredList(input.acceptance_criteria, '验收标准'), expected_result: requiredText(expectedResult, "期望结果"),
priority: requiredEnum(input.priority, '优先级', ['P0', 'P1', 'P2', 'P3']), priority:
impact_scope: optionalEnumList(input.impact_scope, '影响范围', ['前端', '后端', '全栈', '部署']), input.priority === undefined
attachments: optionalList(input.attachments, '附件'), ? "P2"
: requiredEnum(input.priority, "优先级", ["P0", "P1", "P2", "P3"]),
acceptance_criteria: optionalList(input.acceptance_criteria, "验收提示"),
impact_scope: optionalScopeList(input.impact_scope),
constraints: optionalList(input.constraints, "约束"),
non_goals: optionalList(input.non_goals, "非目标"),
attachments: attachmentList(input.attachments),
}; };
for (const [key, label] of [ for (const [key, label] of [
['environment', '发生环境'], ["environment", "发生环境"],
['actual_result', '实际结果'], ["actual_result", "实际结果"],
['expected_result', '期望结果'], ["references", "参考资料"],
['desired_at', '期望完成时间'],
['references', '参考资料'],
]) { ]) {
if (input[key] !== undefined) value[key] = requiredText(input[key], label); if (input[key] !== undefined) value[key] = requiredText(input[key], label);
} }
if (input.desired_at !== undefined)
value.desired_at = normalizeDesiredAt(input.desired_at);
if (input.reproduction_steps !== undefined) if (input.reproduction_steps !== undefined)
value.reproduction_steps = requiredList(input.reproduction_steps, '复现步骤'); value.reproduction_steps = optionalList(input.reproduction_steps, "复现步骤");
if (value.type === 'Bug') {
for (const [key, label] of [
['environment', '发生环境'],
['actual_result', '实际结果'],
['expected_result', '期望结果'],
])
if (!value[key]) throw new Error(`Bug 缺少${label}`);
if (!value.reproduction_steps?.length) throw new Error('Bug 缺少复现步骤');
}
return value; return value;
} }
export function canonical(value) { export function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === 'object') if (value && typeof value === "object")
return `{${Object.keys(value) return `{${Object.keys(value)
.sort() .sort()
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
.join(',')}}`; .join(",")}}`;
return JSON.stringify(value); return JSON.stringify(value);
} }
export function tokens(value) { export function sha256(value) {
const digest = createHash('sha256').update(canonical(value)).digest('hex'); return createHash("sha256").update(value).digest("hex");
return { confirmation_token: digest, submission_key: `intake-${digest.slice(0, 24)}` };
} }
export function preview(value) { export function createDraftIdentity(ownerOpenId) {
const draftId = randomUUID();
const secret = randomBytes(32).toString("base64url");
return { return {
'类型': value.type, draft_id: draftId,
'标题': value.title, confirmation_token: `${draftId}.${secret}`,
'背景与目标': value.background, confirmation_token_hash: sha256(secret),
'验收标准': value.acceptance_criteria, submission_key: `intake-v2-${sha256(`${draftId}:${ownerOpenId}`).slice(0, 24)}`,
'优先级': value.priority,
...(value.environment ? { '发生环境': value.environment } : {}),
...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps } : {}),
...(value.actual_result ? { '实际结果': value.actual_result } : {}),
...(value.expected_result ? { '期望结果': value.expected_result } : {}),
...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}),
...(value.attachments.length ? { '附件': value.attachments } : {}),
}; };
} }
export function parseConfirmationToken(token) {
if (typeof token !== "string")
throw new Error("确认令牌缺失或已过期,请重新预览并确认");
const separator = token.indexOf(".");
const draftId = token.slice(0, separator);
const secret = token.slice(separator + 1);
if (
separator < 1 ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
draftId,
) ||
!secret
)
throw new Error("确认令牌缺失或已过期,请重新预览并确认");
return { draftId, secret };
}
export function verifySecret(secret, expectedHash) {
const actual = Buffer.from(sha256(secret), "hex");
const expected = Buffer.from(String(expectedHash ?? ""), "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function contentDigest(value, ownerOpenId, attachments) {
return sha256(canonical({ value, ownerOpenId, attachments }));
}
export function preview(value, attachmentManifest = []) {
return {
类型: value.type,
标题: value.title,
背景与目标: value.background,
期望结果: value.expected_result,
优先级: value.priority,
...(value.acceptance_criteria.length
? { 可选验收提示: value.acceptance_criteria }
: {}),
...(value.environment ? { 发生环境: value.environment } : {}),
...(value.reproduction_steps?.length
? { 复现步骤: value.reproduction_steps }
: {}),
...(value.actual_result ? { 实际结果: value.actual_result } : {}),
...(value.impact_scope.length ? { 范围提示: value.impact_scope } : {}),
...(value.constraints.length ? { 约束: value.constraints } : {}),
...(value.non_goals.length ? { 非目标: value.non_goals } : {}),
...(value.desired_at ? { 期望完成时间: value.desired_at } : {}),
...(value.references ? { 参考资料: value.references } : {}),
...(attachmentManifest.length
? {
附件: attachmentManifest.map(({ path, size, sha256: digest }) => ({
path,
size,
sha256: digest,
})),
}
: {}),
};
}
export async function attachmentManifest(paths, cwd = process.cwd()) {
const root = await realpath(cwd);
const manifest = [];
for (const path of paths) {
const target = await realpath(resolve(root, path));
const relativePath = relative(root, target);
if (relativePath.startsWith("..") || isAbsolute(relativePath))
throw new Error(`附件超出当前目录: ${path}`);
const metadata = await stat(target);
if (!metadata.isFile()) throw new Error(`附件不是普通文件: ${path}`);
manifest.push({
path,
size: metadata.size,
sha256: createHash("sha256").update(await readFile(target)).digest("hex"),
});
}
return manifest;
}
export async function assertSafeAttachmentPaths(paths, cwd = process.cwd()) {
await attachmentManifest(paths, cwd);
}
export async function readJson(path, fallback) { export async function readJson(path, fallback) {
try { try {
return JSON.parse(await readFile(path, 'utf8')); return JSON.parse(await readFile(path, "utf8"));
} catch (error) { } catch (error) {
if (error?.code === 'ENOENT') return fallback; if (error?.code === "ENOENT") return fallback;
throw error; throw error;
} }
} }
export async function writeJsonAtomic(path, value) {
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
mode: 0o600,
});
await rename(temporary, path);
}
function requiredText(value, label) { function requiredText(value, label) {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label}缺失`); if (typeof value !== "string" || !value.trim())
throw new Error(`${label}缺失`);
return value.trim(); return value.trim();
} }
function requiredList(value, label) { function requiredList(value, label) {
@@ -114,14 +209,50 @@ function optionalList(value, label) {
? [] ? []
: requiredList(value, label); : requiredList(value, label);
} }
function attachmentList(value) {
const list = optionalList(value, "附件");
for (const path of list)
if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
throw new Error(`不安全的附件路径: ${path}`);
return list;
}
function requiredEnum(value, label, options) { function requiredEnum(value, label, options) {
if (!options.includes(value)) throw new Error(`${label}必须是: ${options.join(' / ')}`); if (!options.includes(value))
throw new Error(`${label}必须是: ${options.join(" / ")}`);
return value; return value;
} }
function optionalEnumList(value, label, options) { function optionalEnumList(value, label, options) {
const list = value === undefined || (Array.isArray(value) && value.length === 0) const list = optionalList(value, label);
? []
: requiredList(value, label);
for (const item of list) requiredEnum(item, label, options); for (const item of list) requiredEnum(item, label, options);
return [...new Set(list)]; return [...new Set(list)];
} }
function optionalScopeList(value) {
const list = optionalEnumList(value, "范围提示", [
"前端",
"后端",
"全栈",
"部署",
]);
if (list.includes("全栈") && (list.includes("前端") || list.includes("后端")))
throw new Error("范围提示选择全栈后不能再同时选择前端或后端");
return list;
}
function normalizeDesiredAt(value) {
const text = requiredText(value, "期望完成时间");
const match =
/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(text);
if (!match)
throw new Error("期望完成时间必须是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss");
const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
const [y, m, d, h, min, s] = [year, month, day, hour, minute, second].map(Number);
const validDate =
m >= 1 &&
m <= 12 &&
d >= 1 &&
d <= new Date(Date.UTC(y, m, 0)).getUTCDate() &&
h <= 23 &&
min <= 59 &&
s <= 59;
if (!validDate) throw new Error("期望完成时间不是有效日期时间");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
@@ -1,10 +1,114 @@
#!/usr/bin/env node #!/usr/bin/env node
import { normalize, preview, readStdin, tokens } from './intake-lib.mjs'; import { execFileSync } from "node:child_process";
import { homedir } from "node:os";
import { resolve } from "node:path";
import {
attachmentManifest,
contentDigest,
createDraftIdentity,
normalize,
preview,
readStdin,
writeJsonAtomic,
} from "./intake-lib.mjs";
const intakeHome = resolve(
process.env.EASYAI_INTAKE_HOME ?? homedir(),
".easyai-intake",
);
try { try {
const value = normalize(await readStdin()); const value = normalize(await readStdin());
console.log(JSON.stringify({ ok: true, preview: preview(value), ...tokens(value) }, null, 2)); const auth = lark(["auth", "status", "--json", "--verify"]);
const user = auth.identities?.user;
if (!user?.verified || !user.openId)
throw new Error("当前电脑未完成飞书用户授权");
const attachments = await attachmentManifest(value.attachments);
const identity = createDraftIdentity(user.openId);
const preparedAt = new Date().toISOString();
const draft = {
schema_version: 2,
status_code: "prepared",
draft_id: identity.draft_id,
submission_key: identity.submission_key,
owner_open_id: user.openId,
confirmation_token_hash: identity.confirmation_token_hash,
content_digest: contentDigest(value, user.openId, attachments),
value,
attachments,
prepared_at: preparedAt,
};
await writeJsonAtomic(
resolve(intakeHome, "drafts", `${identity.draft_id}.json`),
draft,
);
console.log(
JSON.stringify(
{
ok: true,
schema_version: 2,
status_code: "prepared",
draft_id: identity.draft_id,
preview: preview(value, attachments),
confirmation_token: identity.confirmation_token,
submission_key: identity.submission_key,
prepared_at: preparedAt,
},
null,
2,
),
);
} catch (error) { } catch (error) {
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); console.error(
JSON.stringify({
ok: false,
error: error instanceof Error ? error.message : "未知错误",
}),
);
process.exitCode = 1; process.exitCode = 1;
} }
function lark(args) {
try {
const output = execFileSync(process.env.EASYAI_LARK_CLI ?? "lark-cli", args, {
encoding: "utf8",
env: {
...process.env,
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1",
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
return unwrapLark(output);
} catch (error) {
for (const output of [error?.stdout, error?.stderr]) {
const message = larkErrorMessage(output);
if (message) throw new Error(message);
}
throw error;
}
}
function unwrapLark(output) {
const envelope = JSON.parse(output);
if (envelope.ok === false)
throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败");
return envelope.data ?? envelope;
}
function larkErrorMessage(output) {
if (typeof output !== "string" || !output.trim()) return undefined;
try {
const envelope = JSON.parse(output);
if (envelope.ok !== false) return undefined;
return (
[envelope.error?.message, envelope.error?.hint]
.filter(Boolean)
.join(": ") || undefined
);
} catch {
return undefined;
}
}
@@ -1,124 +1,452 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execFileSync } from 'node:child_process'; import { execFileSync } from "node:child_process";
import { mkdir, realpath, rename, writeFile } from 'node:fs/promises'; import { mkdir, rmdir } from "node:fs/promises";
import { homedir } from 'node:os'; import { homedir } from "node:os";
import { dirname, isAbsolute, resolve } from 'node:path'; import { resolve } from "node:path";
import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs'; import {
attachmentManifest,
canonical,
contentDigest,
parseConfirmationToken,
readJson,
verifySecret,
writeJsonAtomic,
} from "./intake-lib.mjs";
const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc'; const baseToken = "Ed21b9VwNaZfiesxWgDc67wXnoc";
const formShareToken = 'shrcnT6yRL9olcpFkppFkUcKe5e'; const taskTableId = "tblelMMlOif1YHPD";
const formUrl = 'https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e'; const formShareToken = "shrcnT6yRL9olcpFkppFkUcKe5e";
const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json'); const formUrl =
"https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e";
const intakeHome = resolve(
process.env.EASYAI_INTAKE_HOME ?? homedir(),
".easyai-intake",
);
let lockPath;
let receiptPath;
let receipt;
let submissionStarted = false;
class Completed extends Error {
constructor(output) {
super("completed");
this.output = output;
}
}
try { try {
const value = normalize(await readStdin()); const confirmation = argument("--confirm");
const identity = tokens(value); const { draftId, secret } = parseConfirmationToken(confirmation);
const confirmation = argument('--confirm'); const dryRun = process.argv.includes("--dry-run");
const dryRun = process.argv.includes('--dry-run'); const retryUnknown = process.argv.includes("--retry-unknown");
if (!confirmation || confirmation !== identity.confirmation_token) const draftPath = resolve(intakeHome, "drafts", `${draftId}.json`);
throw new Error('确认令牌缺失或已过期,请重新预览并确认'); const draft = await readJson(draftPath);
if (!draft || draft.schema_version !== 2 || draft.status_code !== "prepared")
throw new Error("确认草稿不存在或已过期,请重新预览");
if (!verifySecret(secret, draft.confirmation_token_hash))
throw new Error("确认令牌缺失或已过期,请重新预览并确认");
const receipts = await readJson(receiptPath, {}); const auth = lark(["auth", "status", "--json", "--verify"]);
if (!dryRun && receipts[identity.submission_key]) { const user = auth.identities?.user;
const existing = receipts[identity.submission_key]; if (!user?.verified || !user.openId)
if (existing.status_code === 'submitting') throw new Error("当前电脑未完成飞书用户授权");
throw new Error(`提交 ${identity.submission_key} 的结果待中央调度器核对,请勿重复提交`); if (user.openId !== draft.owner_open_id)
console.log(JSON.stringify({ ok: true, duplicate: true, ...existing }, null, 2)); throw new Error("当前飞书身份与预览负责人不一致,请重新预览");
process.exit(0);
const attachments = await attachmentManifest(draft.value.attachments);
const currentDigest = contentDigest(draft.value, user.openId, attachments);
if (currentDigest !== draft.content_digest)
throw new Error("内容或附件已改变,旧确认令牌失效,请重新预览");
lockPath = resolve(intakeHome, "locks", draftId);
if (!dryRun) await acquireLock(lockPath);
receiptPath = resolve(intakeHome, "receipts", `${draft.submission_key}.json`);
receipt = await readJson(receiptPath, {
schema_version: 2,
status_code: "prepared",
draft_id: draftId,
submission_key: draft.submission_key,
owner_open_id: user.openId,
prepared_at: draft.prepared_at,
});
if (receipt.status_code === "submitted") {
throw new Completed({ ok: true, duplicate: true, ...receipt });
} }
const auth = lark(['auth', 'status', '--json', '--verify']); const existing = findExistingSubmission(draft.submission_key);
const user = auth.identities?.user; if (existing) {
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权'); finalizeOwnerIntent(draft, existing);
const form = lark(['base', '+form-detail', '--share-token', formShareToken, '--as', 'user']); receipt = submittedReceipt(draft, existing);
if (form.base_token !== baseToken) throw new Error('飞书表单指向了非预期 Base'); if (!dryRun) await writeJsonAtomic(receiptPath, receipt);
const fields = new Map((form.questions ?? []).map((field) => [field.title, field])); throw new Completed({ ok: true, duplicate: true, ...receipt });
for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源']) }
if (!fields.has(name)) throw new Error(`受控表单缺少必需字段: ${name}`);
if (["submitting", "unknown"].includes(receipt.status_code) && !retryUnknown)
throw new Error(
`提交 ${draft.submission_key} 的结果尚未确认;已查询 Base 未找到记录。请核对后使用 --retry-unknown 明确重试,不能盲目重提`,
);
const form = lark([
"base",
"+form-detail",
"--share-token",
formShareToken,
"--as",
"user",
]);
if (form.base_token !== baseToken)
throw new Error("飞书表单指向了非预期 Base");
const fields = new Map(
(form.questions ?? []).map((field) => [field.title, field]),
);
const value = draft.value;
const payload = { const payload = {
'类型': value.type, 类型: value.type,
'标题': value.title, 标题: value.title,
'需求背景与目标': value.background, 需求背景与目标: value.background,
'验收标准': value.acceptance_criteria.map((item, index) => `${index + 1}. ${item}`).join('\n'), 期望结果: value.expected_result,
'优先级': value.priority, 优先级: value.priority,
'产品负责人': [{ id: user.openId }], 产品负责人: [{ id: user.openId }],
'提交幂等键': identity.submission_key, 提交幂等键: draft.submission_key,
'提交来源': 'Codex Skill', 提交来源: "Codex Skill",
...(value.environment ? { '发生环境': value.environment } : {}), ...(value.acceptance_criteria.length
...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps.map((item, index) => `${index + 1}. ${item}`).join('\n') } : {}), ? {
...(value.actual_result ? { '实际结果': value.actual_result } : {}), 验收标准: value.acceptance_criteria
...(value.expected_result ? { '期望结果': value.expected_result } : {}), .map((item, index) => `${index + 1}. ${item}`)
...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}), .join("\n"),
...(value.desired_at ? { '期望完成时间': value.desired_at } : {}), }
...(value.references ? { '参考资料': value.references } : {}), : {}),
...(value.environment ? { 发生环境: value.environment } : {}),
...(value.reproduction_steps?.length
? {
复现步骤: value.reproduction_steps
.map((item, index) => `${index + 1}. ${item}`)
.join("\n"),
}
: {}),
...(value.actual_result ? { 实际结果: value.actual_result } : {}),
...(value.impact_scope.length ? { 影响范围: value.impact_scope } : {}),
...(value.constraints.length || value.non_goals.length
? {
约束与非目标: [
...value.constraints.map((item) => `约束:${item}`),
...value.non_goals.map((item) => `非目标:${item}`),
].join("\n"),
}
: {}),
...(value.desired_at ? { 期望完成时间: value.desired_at } : {}),
...(value.references ? { 参考资料: value.references } : {}),
}; };
for (const attachment of value.attachments) await assertSafePath(attachment); validateLiveForm(fields, payload, value);
const submission = { const submission = {
fields: payload, fields: payload,
...(value.attachments.length ? { attachments: { '附件': value.attachments } } : {}), ...(value.attachments.length
? { attachments: { 附件: value.attachments } }
: {}),
}; };
if (!dryRun) { if (!dryRun) {
receipts[identity.submission_key] = { submissionStarted = true;
receipt = {
...receipt,
status_code: "submitting",
submission_url: formUrl, submission_url: formUrl,
submission_key: identity.submission_key, content_digest: draft.content_digest,
status_code: 'submitting', submitting_at: new Date().toISOString(),
status: '正在提交;进程中断,请等待中央调度器核对,勿重复提交', status: "正在提交;进程中断时先按幂等键核对 Base",
}; };
await writeReceipts(receipts); await writeJsonAtomic(receiptPath, receipt);
} }
const submitted = lark([
'base', '+form-submit', '--share-token', formShareToken, let submitted;
...(value.attachments.length ? ['--base-token', baseToken] : []), try {
'--json', JSON.stringify(submission), '--as', 'user', submitted = lark([
...(dryRun ? ['--dry-run'] : []), "base",
"+form-submit",
"--share-token",
formShareToken,
...(value.attachments.length ? ["--base-token", baseToken] : []),
"--json",
JSON.stringify(submission),
"--as",
"user",
...(dryRun ? ["--dry-run"] : []),
]); ]);
if (!dryRun) {
const created =
recordFromResult(submitted) ??
findExistingSubmission(draft.submission_key);
if (!created) throw new Error("表单返回成功但无法按提交幂等键定位新记录");
finalizeOwnerIntent(draft, created);
submitted = created;
}
} catch (error) {
if (!dryRun) {
receipt = {
...receipt,
status_code: "unknown",
unknown_at: new Date().toISOString(),
status: "提交调用未获得确定结果;重试前必须按提交幂等键查询 Base",
last_error: error instanceof Error ? error.message : "未知错误",
};
await writeJsonAtomic(receiptPath, receipt);
}
throw error;
}
if (dryRun) { if (dryRun) {
console.log(JSON.stringify({ ok: true, dry_run: true, submission_key: identity.submission_key, submitted }, null, 2)); throw new Completed({
process.exit(0); ok: true,
dry_run: true,
draft_id: draftId,
submission_key: draft.submission_key,
submitted,
});
} }
const receipt = { receipt = submittedReceipt(draft, submitted);
submission_url: formUrl, await writeJsonAtomic(receiptPath, receipt);
submission_key: identity.submission_key, console.log(
status_code: 'submitted', JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2),
status: '已登记,等待系统分配任务编号', );
};
receipts[identity.submission_key] = receipt;
await writeReceipts(receipts);
console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2));
} catch (error) { } catch (error) {
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); if (error instanceof Completed) {
console.log(JSON.stringify(error.output, null, 2));
} else {
if (
submissionStarted &&
receiptPath &&
receipt?.status_code === "submitting"
) {
receipt = {
...receipt,
status_code: "unknown",
unknown_at: new Date().toISOString(),
status: "提交进程中断;重试前必须按提交幂等键查询 Base",
last_error: error instanceof Error ? error.message : "未知错误",
};
await writeJsonAtomic(receiptPath, receipt).catch(() => undefined);
}
console.error(
JSON.stringify({
ok: false,
status_code: receipt?.status_code,
submission_key: receipt?.submission_key,
error: error instanceof Error ? error.message : "未知错误",
}),
);
process.exitCode = 1; process.exitCode = 1;
}
} finally {
if (lockPath) await rmdir(lockPath).catch(() => undefined);
}
function submittedReceipt(draft, result) {
const record = recordFromResult(result) ?? result;
return {
schema_version: 2,
submission_url: formUrl,
draft_id: draft.draft_id,
submission_key: draft.submission_key,
owner_open_id: draft.owner_open_id,
content_digest: draft.content_digest,
status_code: "submitted",
status: "已登记,等待系统分配任务编号",
submitted_at: new Date().toISOString(),
...(record?.record_id ? { record_id: record.record_id } : {}),
};
}
function recordFromResult(result) {
return (
result?.record ??
result?.data?.[0] ??
(result?.record_id ? result : undefined)
);
}
function finalizeOwnerIntent(draft, record) {
const recordId = record?.record_id;
if (!recordId)
throw new Error("已登记记录缺少 record_id,无法固化原始意图指纹");
lark([
"base",
"+record-upsert",
"--base-token",
baseToken,
"--table-id",
taskTableId,
"--record-id",
recordId,
"--json",
JSON.stringify({ 原始意图指纹: draft.content_digest }),
"--as",
"user",
]);
}
function findExistingSubmission(submissionKey) {
const result = lark([
"base",
"+record-search",
"--base-token",
baseToken,
"--table-id",
taskTableId,
"--keyword",
submissionKey,
"--search-field",
"提交幂等键",
"--field-id",
"提交幂等键",
"--field-id",
"任务编号",
"--limit",
"2",
"--format",
"json",
"--as",
"user",
]);
const records = Array.isArray(result?.data) ? result.data : [];
return records.find((record) => {
const fields = record.fields ?? record;
return scalarText(fields["提交幂等键"]) === submissionKey;
});
}
function validateLiveForm(fields, payload, value) {
const expected = {
类型: "select",
标题: "text",
需求背景与目标: "text",
期望结果: "text",
优先级: "select",
产品负责人: "user",
提交幂等键: "text",
提交来源: "select",
};
for (const [name, type] of Object.entries(expected))
assertField(fields, name, type);
for (const name of Object.keys(payload))
if (!fields.has(name)) throw new Error(`受控表单缺少字段: ${name}`);
for (const field of fields.values())
if (
field.required &&
field.title !== "任务编号(系统自动生成,请勿填写)" &&
!hasPayloadValue(payload[field.title])
)
throw new Error(`受控表单仍要求非 V2 必填字段: ${field.title}`);
assertSelectOptions(fields, "类型", [value.type]);
assertSelectOptions(fields, "优先级", [value.priority]);
assertSelectOptions(fields, "提交来源", ["Codex Skill"]);
if (value.impact_scope.length)
assertSelectOptions(fields, "影响范围", value.impact_scope);
if (value.attachments.length) assertField(fields, "附件", "attachment");
}
function assertField(fields, name, type) {
const field = fields.get(name);
if (!field) throw new Error(`受控表单缺少必需字段: ${name}`);
if (field.type !== type)
throw new Error(
`受控表单字段 ${name} 类型错误: 期望 ${type},实际 ${field.type}`,
);
}
function hasPayloadValue(value) {
return (
value !== undefined &&
value !== null &&
value !== "" &&
(!Array.isArray(value) || value.length > 0)
);
}
function scalarText(value) {
if (typeof value === "string") return value;
if (Array.isArray(value))
return value
.map((part) => (typeof part === "string" ? part : (part?.text ?? "")))
.join("");
return value?.text ?? "";
}
async function acquireLock(path) {
try {
await mkdir(resolve(path, ".."), { recursive: true, mode: 0o700 });
await mkdir(path, { mode: 0o700 });
} catch (error) {
if (error?.code === "EEXIST")
throw new Error("同一草稿正在提交,请等待当前提交完成");
throw error;
}
} }
function lark(args) { function lark(args) {
const output = execFileSync('lark-cli', args, { try {
encoding: 'utf8', const output = execFileSync(
env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1', LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1' }, process.env.EASYAI_LARK_CLI ?? "lark-cli",
stdio: ['ignore', 'pipe', 'pipe'], args,
}); {
const envelope = JSON.parse(output); encoding: "utf8",
if (envelope.ok === false) throw new Error([envelope.error?.message, envelope.error?.hint].filter(Boolean).join(': ')); env: {
return envelope.data ?? envelope; ...process.env,
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1",
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
return unwrapLark(output);
} catch (error) {
for (const output of [error?.stdout, error?.stderr]) {
const message = larkErrorMessage(output);
if (message) throw new Error(message);
}
throw error;
}
} }
function argument(name) { function argument(name) {
const index = process.argv.indexOf(name); const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined; return index >= 0 ? process.argv[index + 1] : undefined;
} }
async function assertSafePath(path) {
if (typeof path !== 'string' || !path || isAbsolute(path) || path.split('/').includes('..')) function unwrapLark(output) {
throw new Error(`不安全的附件路径: ${path}`); const envelope = JSON.parse(output);
const root = await realpath(process.cwd()); if (envelope.ok === false)
const target = await realpath(resolve(root, path)); throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败");
if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`); return envelope.data ?? envelope;
} }
async function writeReceipts(receipts) {
const directory = dirname(receiptPath); function larkErrorMessage(output) {
const temporary = `${receiptPath}.${process.pid}.tmp`; if (typeof output !== "string" || !output.trim()) return undefined;
await mkdir(directory, { recursive: true, mode: 0o700 }); try {
await writeFile(temporary, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 }); const envelope = JSON.parse(output);
await rename(temporary, receiptPath); if (envelope.ok !== false) return undefined;
return (
[envelope.error?.message, envelope.error?.hint]
.filter(Boolean)
.join(": ") || undefined
);
} catch {
return undefined;
}
} }
function assertSelectOptions(fields, name, values) {
const options = new Set(
(fields.get(name)?.options ?? []).map((option) => option.name),
);
for (const value of values)
if (!options.has(value))
throw new Error(`受控表单字段 ${name} 缺少选项: ${value}`);
}
// Used by focused tests to prove that attachment metadata participates in a draft.
export const __test = { canonical };
@@ -0,0 +1,308 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
attachmentManifest,
contentDigest,
createDraftIdentity,
normalize,
parseConfirmationToken,
preview,
verifySecret,
} from "./intake-lib.mjs";
const scriptRoot = dirname(fileURLToPath(import.meta.url));
const temporary = await mkdtemp(resolve(tmpdir(), "easyai-intake-v2-"));
try {
const minimal = normalize({
type: "需求",
title: "在设置页展示版本号",
problem_or_goal: "便于支持人员确认当前版本",
expected_outcome: "设置页可看到当前前端版本号",
});
assert.equal(minimal.priority, "P2");
assert.deepEqual(minimal.acceptance_criteria, []);
assert.deepEqual(minimal.impact_scope, []);
assert.deepEqual(preview(minimal), {
类型: "需求",
标题: "在设置页展示版本号",
背景与目标: "便于支持人员确认当前版本",
期望结果: "设置页可看到当前前端版本号",
优先级: "P2",
});
const incompleteBug = normalize({
type: "Bug",
title: "保存后页面未更新",
background: "负责人观察到保存结果没有出现在列表",
expected_result: "保存成功后列表显示新数据",
});
assert.equal(incompleteBug.environment, undefined);
assert.deepEqual(incompleteBug.reproduction_steps, undefined);
assert.throws(
() => normalize({ type: "需求", title: "x", background: "y" }),
/期望结果缺失/,
);
assert.throws(
() =>
normalize({
...minimalInput(),
impact_scope: ["全栈", "前端"],
}),
/不能再同时选择/,
);
assert.doesNotThrow(() =>
normalize({ ...minimalInput(), impact_scope: ["部署"] }),
);
assert.throws(
() => normalize({ ...minimalInput(), desired_at: "2026-02-30" }),
/不是有效日期时间/,
);
assert.throws(
() => normalize({ ...minimalInput(), attachments: ["../secret.txt"] }),
/不安全的附件路径/,
);
assert.throws(
() => normalize({ ...minimalInput(), task_id: "EAI-9999" }),
/禁止或未知字段/,
);
const firstIdentity = createDraftIdentity("ou_owner");
const secondIdentity = createDraftIdentity("ou_owner");
assert.notEqual(firstIdentity.draft_id, secondIdentity.draft_id);
assert.notEqual(firstIdentity.submission_key, secondIdentity.submission_key);
const parsed = parseConfirmationToken(firstIdentity.confirmation_token);
assert.equal(parsed.draftId, firstIdentity.draft_id);
assert.equal(
verifySecret(parsed.secret, firstIdentity.confirmation_token_hash),
true,
);
await writeFile(resolve(temporary, "evidence.txt"), "first");
const attachmentValue = normalize({
...minimalInput(),
attachments: ["evidence.txt"],
});
const firstManifest = await attachmentManifest(
attachmentValue.attachments,
temporary,
);
const firstDigest = contentDigest(attachmentValue, "ou_owner", firstManifest);
await writeFile(resolve(temporary, "evidence.txt"), "second");
const secondManifest = await attachmentManifest(
attachmentValue.attachments,
temporary,
);
assert.notEqual(
firstDigest,
contentDigest(attachmentValue, "ou_owner", secondManifest),
);
const fakeCli = resolve(temporary, "fake-lark-cli.mjs");
await writeFile(fakeCli, fakeLarkCli(), { mode: 0o700 });
await chmod(fakeCli, 0o700);
const intakeHome = resolve(temporary, "home");
const env = {
...process.env,
EASYAI_INTAKE_HOME: intakeHome,
EASYAI_LARK_CLI: fakeCli,
};
const prepared = runJson(
"prepare-intake.mjs",
[],
{ ...minimalInput(), constraints: ["必须复用既有组件"] },
env,
);
assert.equal(prepared.status_code, "prepared");
assert.match(
prepared.confirmation_token,
new RegExp(`^${prepared.draft_id}\\.`),
);
const draft = JSON.parse(
await readFile(
resolve(
intakeHome,
".easyai-intake",
"drafts",
`${prepared.draft_id}.json`,
),
"utf8",
),
);
assert.equal(draft.owner_open_id, "ou_owner");
assert.equal(draft.status_code, "prepared");
const dryRun = runJson(
"submit-intake.mjs",
["--confirm", prepared.confirmation_token, "--dry-run"],
undefined,
env,
);
assert.equal(dryRun.dry_run, true);
const submitted = runJson(
"submit-intake.mjs",
["--confirm", prepared.confirmation_token],
undefined,
env,
);
assert.equal(submitted.status_code, "submitted");
const duplicate = runJson(
"submit-intake.mjs",
["--confirm", prepared.confirmation_token],
undefined,
env,
);
assert.equal(duplicate.duplicate, true);
const unknownDraft = runJson(
"prepare-intake.mjs",
[],
{ ...minimalInput(), title: "未知结果恢复" },
env,
);
const failed = runJson(
"submit-intake.mjs",
["--confirm", unknownDraft.confirmation_token],
undefined,
{ ...env, EASYAI_FAKE_FAIL_FORM: "1" },
1,
);
assert.equal(failed.status_code, "unknown");
const blockedRetry = runJson(
"submit-intake.mjs",
["--confirm", unknownDraft.confirmation_token],
undefined,
env,
1,
);
assert.match(blockedRetry.error, /--retry-unknown/);
const recovered = runJson(
"submit-intake.mjs",
["--confirm", unknownDraft.confirmation_token, "--retry-unknown"],
undefined,
env,
);
assert.equal(recovered.status_code, "submitted");
const lockedDraft = runJson(
"prepare-intake.mjs",
[],
{ ...minimalInput(), title: "并发锁测试" },
env,
);
await mkdir(
resolve(intakeHome, ".easyai-intake", "locks", lockedDraft.draft_id),
{ recursive: true },
);
const locked = runJson(
"submit-intake.mjs",
["--confirm", lockedDraft.confirmation_token],
undefined,
env,
1,
);
assert.match(locked.error, /正在提交/);
console.log("intake v2 tests passed");
} finally {
await rm(temporary, { recursive: true, force: true });
}
function minimalInput() {
return {
type: "需求",
title: "在设置页展示版本号",
background: "便于支持人员确认当前版本",
expected_result: "设置页可看到当前前端版本号",
};
}
function runJson(script, args, input, env, expectedStatus = 0) {
const result = spawnSync(
process.execPath,
[resolve(scriptRoot, script), ...args],
{
cwd: temporary,
env,
encoding: "utf8",
input: input === undefined ? undefined : JSON.stringify(input),
},
);
assert.equal(
result.status,
expectedStatus,
`${script} status=${result.status}\nstdout=${result.stdout}\nstderr=${result.stderr}`,
);
return JSON.parse(
(expectedStatus === 0 ? result.stdout : result.stderr).trim(),
);
}
function fakeLarkCli() {
return `#!/usr/bin/env node
const args = process.argv.slice(2);
const ok = (data) => console.log(JSON.stringify({ ok: true, data }));
if (args[0] === "auth") {
ok({ identities: { user: { verified: true, openId: "ou_owner" } } });
} else if (args.includes("+form-detail")) {
const question = (title, type, required = false, options = []) => ({
title, type, required, options: options.map((name) => ({ name })),
});
ok({
base_token: "Ed21b9VwNaZfiesxWgDc67wXnoc",
questions: [
question("类型", "select", true, ["需求", "Bug"]),
question("标题", "text", true),
question("需求背景与目标", "text", true),
question("期望结果", "text", true),
question("优先级", "select", true, ["P0", "P1", "P2", "P3"]),
question("产品负责人", "user", true),
question("提交幂等键", "text", true),
question("提交来源", "select", true, ["Codex Skill"]),
question("验收标准", "text"),
question("发生环境", "text"),
question("复现步骤", "text"),
question("实际结果", "text"),
question("影响范围", "select", false, ["前端", "后端", "全栈", "部署"]),
question("约束与非目标", "text"),
question("期望完成时间", "datetime"),
question("参考资料", "text"),
question("附件", "attachment"),
],
});
} else if (args.includes("+record-search")) {
ok({ data: [], record_id_list: [] });
} else if (args.includes("+form-submit")) {
if (process.env.EASYAI_FAKE_FAIL_FORM === "1") {
console.error(JSON.stringify({ ok: false, error: { message: "network interrupted" } }));
process.exit(1);
}
ok({ record: { record_id: "rec_test" } });
} else if (args.includes("+record-upsert")) {
const payload = JSON.parse(args[args.indexOf("--json") + 1] ?? "{}");
if (!/^[a-f0-9]{64}$/.test(payload["原始意图指纹"] ?? "")) {
console.error(JSON.stringify({ ok: false, error: { message: "missing owner intent fingerprint" } }));
process.exit(1);
}
ok({ record_id: "rec_test" });
} else {
console.error(JSON.stringify({ ok: false, error: { message: "unexpected command: " + args.join(" ") } }));
process.exit(1);
}
`;
}