commit dcbbe886faee49dd6a8c0d1884b8f4f1c3514aa3 Author: easyai-auto Date: Tue Aug 11 11:42:58 2026 +0800 feat(插件): 新增产品需求提交 Skill diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..22620c2 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "easyai-team", + "interface": { + "displayName": "EasyAI Team" + }, + "plugins": [ + { + "name": "easyai-product-intake", + "source": { + "source": "local", + "path": "./plugins/easyai-product-intake" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646ac51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules/ diff --git a/plugins/easyai-product-intake/.codex-plugin/plugin.json b/plugins/easyai-product-intake/.codex-plugin/plugin.json new file mode 100644 index 0000000..fc96481 --- /dev/null +++ b/plugins/easyai-product-intake/.codex-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "easyai-product-intake", + "version": "0.1.0+codex.20260811033837", + "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", + "author": { + "name": "EasyAI" + }, + "skills": "./skills/", + "interface": { + "displayName": "EasyAI \u63d0\u9700\u6c42/\u62a5 Bug", + "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", + "developerName": "EasyAI", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "defaultPrompt": [ + "\u63d0\u4ea4\u4e00\u4e2a\u65b0\u7684\u4ea7\u54c1\u9700\u6c42\u3002", + "\u6211\u8981\u62a5\u4e00\u4e2a Bug\u3002" + ] + } +} diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md b/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md new file mode 100644 index 0000000..e3af1a8 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md @@ -0,0 +1,39 @@ +--- +name: easyai-submit-task +description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表格。当用户说“提需求”“新建需求”“报 Bug”“反馈问题”“登记产品任务”或要把产品想法提交给 Codex 处理时使用。 +--- + +# EasyAI 提需求/报 Bug + +## 执行流程 + +1. 读取 [intake-rules.md](references/intake-rules.md) 判断类型和必填信息。 +2. 检查用户已给出的信息。每轮最多追问 3 个对验收有实质影响的问题,不询问仓库、分支或技术方案。 +3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs`。 +4. 展示脚本返回的中文预览。必须等待用户明确说“确认提交”或等价表达。 +5. 确认后,将同一 JSON 通过 stdin 传给 `scripts/submit-intake.mjs --confirm `。 +6. 返回 `record_id`、提交幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成。 + +## 命令 + +```bash +printf '%s' '' | node scripts/prepare-intake.mjs +printf '%s' '' | node scripts/submit-intake.mjs --confirm '' +``` + +`submit-intake.mjs` 使用当前电脑已授权的飞书用户身份,不要要求用户提供 token、Cookie 或密码。 + +## 安全边界 + +- 只能写入 [base-schema.md](references/base-schema.md) 列出的产品字段。 +- 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。 +- 不经用户确认不得运行提交脚本。 +- 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。 +- 提交失败时保留原输入并报告结构化错误,不得改写产品意图后自动重试。 + +## 返回规则 + +- 信息不完整:只返回下一组最多 3 个问题。 +- 待确认:展示类型、标题、背景、验收标准、优先级和附件。 +- 提交成功:明确说明“已登记,等待系统分配任务编号”。 +- 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。 diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml b/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml new file mode 100644 index 0000000..a412b54 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "EasyAI 提需求/报 Bug" + short_description: "规范收集产品需求和 Bug 并安全提交到飞书多维表格" + default_prompt: "Use $easyai-submit-task to submit a product requirement or bug to the EasyAI Feishu Base." diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md b/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md new file mode 100644 index 0000000..b248239 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md @@ -0,0 +1,26 @@ +# 飞书 Base 字段契约 + +- Base Token: `Ed21b9VwNaZfiesxWgDc67wXnoc` +- 数据表: `tblelMMlOif1YHPD`(需求与缺陷) + +允许提交的字段: + +| JSON 属性 | Base 字段 | 值 | +|---|---|---| +| `type` | 类型 | `需求` / `Bug` | +| `title` | 标题 | 非空文本 | +| `background` | 需求背景与目标 | 非空文本 | +| `acceptance_criteria` | 验收标准 | 非空字符串数组 | +| `priority` | 优先级 | `P0` / `P1` / `P2` / `P3` | +| `environment` | 发生环境 | Bug 必填 | +| `reproduction_steps` | 复现步骤 | Bug 必填字符串数组 | +| `actual_result` | 实际结果 | Bug 必填 | +| `expected_result` | 期望结果 | Bug 必填 | +| `impact_scope` | 影响范围 | `前端` / `后端` / `全栈` / `部署` 数组,可选 | +| `desired_at` | 期望完成时间 | `YYYY-MM-DD HH:mm:ss`,可选 | +| `references` | 参考资料 | 可选文本 | +| `attachments` | 附件 | 当前目录内相对路径数组 | + +脚本自动写入但不接受模型指定:`产品负责人`、`提交幂等键`、`提交来源`。 + +其他字段全部禁止写入。 diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md b/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md new file mode 100644 index 0000000..2ba90fa --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md @@ -0,0 +1,24 @@ +# 需求与 Bug 收集规则 + +## 共同必填 + +- 类型、标题、背景与目标、可执行的验收标准、优先级。 +- 验收标准要描述可观察结果,避免“优化一下”“体验更好”等无法判定的表达。 +- 产品负责人由当前飞书授权身份自动填入,不询问 open_id。 + +## Bug 额外必填 + +- 发生环境、复现步骤、实际结果、期望结果。 +- 有截图、视频或日志时作为附件;无法提供时明确记录“暂无证据”,不伪造证据。 + +## 追问优先级 + +1. 先问会影响是否可执行和可验收的信息。 +2. 再问用户范围、兼容性或时间限制。 +3. 不要让产品选择仓库、技术框架、实现类或数据库结构。 + +## 首个真实 Canary 条件 + +- 范围清晰、一天内可完成、验收结果确定、可回滚。 +- 不涉及支付、权限、数据迁移、数据库结构或破坏性操作。 +- 不依赖尚未接入的外部供应商。 diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs new file mode 100644 index 0000000..bd39b28 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs @@ -0,0 +1,125 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +const allowedKeys = new Set([ + 'type', + 'title', + 'background', + 'acceptance_criteria', + 'priority', + 'environment', + 'reproduction_steps', + 'actual_result', + 'expected_result', + 'impact_scope', + 'desired_at', + 'references', + 'attachments', +]); + +export async function readStdin() { + let input = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) input += chunk; + if (!input.trim()) throw new Error('请通过 stdin 传入 JSON'); + return JSON.parse(input); +} + +export function normalize(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('输入必须是 JSON 对象'); + const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key)); + if (unknown.length) throw new Error(`存在禁止或未知字段: ${unknown.join(', ')}`); + const value = { + type: requiredEnum(input.type, '类型', ['需求', 'Bug']), + title: requiredText(input.title, '标题'), + background: requiredText(input.background, '背景与目标'), + acceptance_criteria: requiredList(input.acceptance_criteria, '验收标准'), + priority: requiredEnum(input.priority, '优先级', ['P0', 'P1', 'P2', 'P3']), + impact_scope: optionalEnumList(input.impact_scope, '影响范围', ['前端', '后端', '全栈', '部署']), + attachments: optionalList(input.attachments, '附件'), + }; + for (const [key, label] of [ + ['environment', '发生环境'], + ['actual_result', '实际结果'], + ['expected_result', '期望结果'], + ['desired_at', '期望完成时间'], + ['references', '参考资料'], + ]) { + if (input[key] !== undefined) value[key] = requiredText(input[key], label); + } + if (input.reproduction_steps !== undefined) + value.reproduction_steps = requiredList(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; +} + +export function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(',')}}`; + return JSON.stringify(value); +} + +export function tokens(value) { + const digest = createHash('sha256').update(canonical(value)).digest('hex'); + return { confirmation_token: digest, submission_key: `intake-${digest.slice(0, 24)}` }; +} + +export function preview(value) { + return { + '类型': value.type, + '标题': value.title, + '背景与目标': value.background, + '验收标准': value.acceptance_criteria, + '优先级': 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 async function readJson(path, fallback) { + try { + return JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return fallback; + throw error; + } +} + +function requiredText(value, label) { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${label}缺失`); + return value.trim(); +} +function requiredList(value, label) { + if (!Array.isArray(value) || !value.length) throw new Error(`${label}缺失`); + return value.map((item) => requiredText(item, label)); +} +function optionalList(value, label) { + return value === undefined || (Array.isArray(value) && value.length === 0) + ? [] + : requiredList(value, label); +} +function requiredEnum(value, label, options) { + if (!options.includes(value)) throw new Error(`${label}必须是: ${options.join(' / ')}`); + return value; +} +function optionalEnumList(value, label, options) { + const list = value === undefined ? [] : requiredList(value, label); + for (const item of list) requiredEnum(item, label, options); + return [...new Set(list)]; +} diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs new file mode 100644 index 0000000..4f8f481 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { normalize, preview, readStdin, tokens } from './intake-lib.mjs'; + +try { + const value = normalize(await readStdin()); + console.log(JSON.stringify({ ok: true, preview: preview(value), ...tokens(value) }, null, 2)); +} catch (error) { + console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); + process.exitCode = 1; +} diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs new file mode 100644 index 0000000..7e52b58 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs'; + +const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc'; +const tableId = 'tblelMMlOif1YHPD'; +const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json'); + +try { + const value = normalize(await readStdin()); + const identity = tokens(value); + const confirmation = argument('--confirm'); + if (!confirmation || confirmation !== identity.confirmation_token) + throw new Error('确认令牌缺失或已过期,请重新预览并确认'); + + const receipts = await readJson(receiptPath, {}); + if (receipts[identity.submission_key]) { + console.log(JSON.stringify({ ok: true, duplicate: true, ...receipts[identity.submission_key] }, null, 2)); + process.exit(0); + } + + const auth = lark(['auth', 'status', '--json', '--verify']); + const user = auth.identities?.user; + if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权'); + const fieldList = lark(['base', '+field-list', '--base-token', baseToken, '--table-id', tableId, '--as', 'user']); + const fields = new Map((fieldList.fields ?? []).map((field) => [field.name, field])); + for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源']) + if (!fields.has(name)) throw new Error(`Base 缺少必需字段: ${name}`); + + const payload = { + '类型': value.type, + '标题': value.title, + '需求背景与目标': value.background, + '验收标准': value.acceptance_criteria.map((item, index) => `${index + 1}. ${item}`).join('\n'), + '优先级': value.priority, + '产品负责人': [{ id: user.openId }], + '提交幂等键': identity.submission_key, + '提交来源': 'Codex Skill', + ...(value.environment ? { '发生环境': value.environment } : {}), + ...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps.map((item, index) => `${index + 1}. ${item}`).join('\n') } : {}), + ...(value.actual_result ? { '实际结果': value.actual_result } : {}), + ...(value.expected_result ? { '期望结果': value.expected_result } : {}), + ...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}), + ...(value.desired_at ? { '期望完成时间': value.desired_at } : {}), + ...(value.references ? { '参考资料': value.references } : {}), + }; + const created = lark([ + 'base', '+record-upsert', '--base-token', baseToken, '--table-id', tableId, + '--json', JSON.stringify(payload), '--as', 'user', + ]); + const recordId = findId(created, 'rec'); + if (!recordId) throw new Error('飞书未返回 record_id'); + + const recordUrl = findUrl(created) ?? `https://feishu.cn/base/${baseToken}?table=${tableId}&record=${recordId}`; + const receipt = { + record_id: recordId, + record_url: recordUrl, + submission_key: identity.submission_key, + status: '已登记,等待系统分配任务编号', + }; + receipts[identity.submission_key] = receipt; + await mkdir(dirname(receiptPath), { recursive: true, mode: 0o700 }); + await writeFile(receiptPath, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 }); + + const attachmentField = fields.get('附件'); + for (const attachment of value.attachments) { + if (!attachmentField?.id) throw new Error('Base 缺少附件字段'); + await assertSafePath(attachment); + lark([ + 'base', '+record-upload-attachment', '--base-token', baseToken, '--table-id', tableId, + '--record-id', recordId, '--field-id', attachmentField.id, '--file', attachment, '--as', 'user', + ]); + } + console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2)); +} catch (error) { + console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); + process.exitCode = 1; +} + +function lark(args) { + const output = execFileSync('lark-cli', args, { + encoding: 'utf8', + env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1', LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const envelope = JSON.parse(output); + if (envelope.ok === false) throw new Error([envelope.error?.message, envelope.error?.hint].filter(Boolean).join(': ')); + return envelope.data ?? envelope; +} +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} +async function assertSafePath(path) { + if (typeof path !== 'string' || !path || isAbsolute(path) || path.split('/').includes('..')) + throw new Error(`不安全的附件路径: ${path}`); + const root = await realpath(process.cwd()); + const target = await realpath(resolve(root, path)); + if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`); +} +function findId(value, prefix) { + if (typeof value === 'string' && value.startsWith(prefix)) return value; + if (Array.isArray(value)) for (const item of value) { const found = findId(item, prefix); if (found) return found; } + if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findId(item, prefix); if (found) return found; } +} +function findUrl(value) { + if (typeof value === 'string' && /^https?:\/\//.test(value)) return value; + if (Array.isArray(value)) for (const item of value) { const found = findUrl(item); if (found) return found; } + if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findUrl(item); if (found) return found; } +}