feat(插件): 新增产品需求提交 Skill
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 <confirmation_token>`。
|
||||
6. 返回 `record_id`、提交幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
printf '%s' '<json>' | node scripts/prepare-intake.mjs
|
||||
printf '%s' '<json>' | node scripts/submit-intake.mjs --confirm '<confirmation_token>'
|
||||
```
|
||||
|
||||
`submit-intake.mjs` 使用当前电脑已授权的飞书用户身份,不要要求用户提供 token、Cookie 或密码。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 只能写入 [base-schema.md](references/base-schema.md) 列出的产品字段。
|
||||
- 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。
|
||||
- 不经用户确认不得运行提交脚本。
|
||||
- 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。
|
||||
- 提交失败时保留原输入并报告结构化错误,不得改写产品意图后自动重试。
|
||||
|
||||
## 返回规则
|
||||
|
||||
- 信息不完整:只返回下一组最多 3 个问题。
|
||||
- 待确认:展示类型、标题、背景、验收标准、优先级和附件。
|
||||
- 提交成功:明确说明“已登记,等待系统分配任务编号”。
|
||||
- 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。
|
||||
@@ -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."
|
||||
@@ -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` | 附件 | 当前目录内相对路径数组 |
|
||||
|
||||
脚本自动写入但不接受模型指定:`产品负责人`、`提交幂等键`、`提交来源`。
|
||||
|
||||
其他字段全部禁止写入。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 需求与 Bug 收集规则
|
||||
|
||||
## 共同必填
|
||||
|
||||
- 类型、标题、背景与目标、可执行的验收标准、优先级。
|
||||
- 验收标准要描述可观察结果,避免“优化一下”“体验更好”等无法判定的表达。
|
||||
- 产品负责人由当前飞书授权身份自动填入,不询问 open_id。
|
||||
|
||||
## Bug 额外必填
|
||||
|
||||
- 发生环境、复现步骤、实际结果、期望结果。
|
||||
- 有截图、视频或日志时作为附件;无法提供时明确记录“暂无证据”,不伪造证据。
|
||||
|
||||
## 追问优先级
|
||||
|
||||
1. 先问会影响是否可执行和可验收的信息。
|
||||
2. 再问用户范围、兼容性或时间限制。
|
||||
3. 不要让产品选择仓库、技术框架、实现类或数据库结构。
|
||||
|
||||
## 首个真实 Canary 条件
|
||||
|
||||
- 范围清晰、一天内可完成、验收结果确定、可回滚。
|
||||
- 不涉及支付、权限、数据迁移、数据库结构或破坏性操作。
|
||||
- 不依赖尚未接入的外部供应商。
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user