hookehuyr

feat: 添加 Speckit 相关配置与实现文件

新增 Speckit 工具链相关文件,包括:
- 添加 specs/.gitkeep 文件
- 在 package.json 中添加 mcp:speckit 脚本
- 添加 .github/prompts 下的多个 prompt 文件
- 添加 .specify/memory/constitution.md 项目原则文件
- 添加 src/docs 下的 Speckit 测试文档和硬核模板
- 实现 mcp/speckit_stdio_server.js 服务端逻辑
---
description: "生成/更新项目原则(constitution)"
agent: agent
---
请根据当前仓库的技术栈与业务场景,生成或更新 .specify/memory/constitution.md:
- 内容使用中文
- 原则需要覆盖:工程规范、UI/样式规范、网络请求返回结构、资源链接规则、开发与发布约束
- 原则要具体可执行,避免空泛口号
- 如果已存在同名文件,保留现有有效条款,只补齐缺失部分并做结构化整理
---
description: "按任务实现并自检(implement)"
agent: agent
argument-hint: "feature_key(例如 001-xxx)"
---
你需要按 specs/<feature_key>/tasks.md 逐条完成实现,并遵循:
- 严格对照 specs/<feature_key>/spec.md 的验收标准
- 修改尽量小,避免无关重构
- 接口返回结构固定为 { code, data, msg },错误提示使用 msg
- 图片域名为 cdn.ipadbiz.cn 时追加 ?imageMogr2/thumbnail/200x/strip/quality/70
- 完成后运行现有的测试/检查脚本(如果仓库提供)
- 不要启动或重启任何服务;不要引导打开预览
---
description: "生成技术方案(plan)"
agent: agent
argument-hint: "feature_key(例如 001-xxx)"
---
基于 specs/<feature_key>/spec.md,生成 specs/<feature_key>/plan.md。
约束:
- 给出最小改动方案,优先复用现有组件、工具与目录结构
- 明确需要修改/新增的文件清单(按路径列出)
- 明确数据流、状态管理方式(如需要 pinia/route query/组件本地状态)
- 明确接口调用点与返回结构 { code, data, msg } 的处理策略
- 样式:优先 TailwindCSS 类名(项目已引入),Less 用于层级补充
- 不要启动或重启任何服务;不要引导打开预览
plan.md 输出结构:
1) 总体方案
2) 页面与路由变更
3) 组件与复用点
4) 状态管理与数据流
5) 接口与错误处理
6) 样式与响应式策略
7) 影响范围与回归清单
---
description: "生成需求规格说明(spec)"
agent: agent
argument-hint: "feature_key 与需求描述(例如 001-xxx ...)"
---
你需要把用户的需求整理成可评审的规格说明,输出到 specs/<feature_key>/spec.md。
约束:
- 只写“做什么/为什么”,不要写实现细节
- 不确定的信息必须写成 [NEEDS CLARIFICATION],并给出具体要问的问题
- 规格说明使用中文,结构清晰,便于团队协作评审
- feature_key 使用数字前缀与短横线命名,例如 001-course-share-poster
spec.md 模板:
1) 背景与目标
- 背景:
- 目标:
- 非目标:
2) 用户画像与使用场景
3) 需求范围
- 页面/入口:
- 核心流程:
- 状态与异常分支:
4) 用户故事(User Stories)
- 作为……我想……以便……
5) 验收标准(Acceptance Criteria)
- 使用可验证的条件描述
6) 交互与视觉要点
- 移动端优先(如涉及 PC/移动差异需要说明)
7) 数据与接口
- 需要的字段(仅描述数据含义,不给实现)
- 返回结构固定为 { code, data, msg }
8) 边界条件与风险
9) 待确认事项
- [NEEDS CLARIFICATION] ...
---
description: "从方案拆分可执行任务(tasks)"
agent: agent
argument-hint: "feature_key(例如 001-xxx)"
---
基于 specs/<feature_key>/plan.md,生成 specs/<feature_key>/tasks.md。
约束:
- 任务要可执行、可验收,按顺序列出
- 每条任务不超过 14 个字
- 标注必要的回归点
- 不要包含“启动服务/打开预览”相关任务
tasks.md 格式:
- [ ] 任务1
- [ ] 任务2
回归清单:
- [ ] 回归项1
- [ ] 回归项2
## 项目原则
- 以用户需求为中心,优先实现最小可用闭环,避免过度设计
- 修改应尽量小且可回滚,避免大范围重构
- 保持现有代码风格与目录结构一致,优先复用已有工具与组件
- 生产代码完毕后不要重启服务
- 操作完成后不要自动打开预览
## 技术与工程约束
- 依赖管理使用 pnpm
- 构建工具使用 Vite
- 前端使用 Vue3(优先 setup 语法糖)
- 移动端 UI 优先使用 Vant
- 样式使用 Less;如果项目已引入 TailwindCSS,布局优先用 Tailwind 类名,细节用 Less 补充
- 路由使用 vue-router
- 状态管理使用 pinia(如项目已接入)
- 不引入不必要的新库;如必须新增依赖,需要在方案中说明原因与替代方案
## 接口与数据约束
- 所有网络请求返回对象结构必须为 { code, data, msg }
- code=1 表示成功,其他值表示失败;失败时保持 msg 可读
## 资源与链接约束
- 若图片域名为 cdn.ipadbiz.cn,需要自动追加 ?imageMogr2/thumbnail/200x/strip/quality/70
import { spawn } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const PROTOCOL_VERSIONS = ['2024-11-05', '2025-06-18']
const server_info = {
name: 'speckit-cli-mcp',
title: 'Speckit CLI MCP Server',
version: '0.1.0',
}
const module_dirname = path.dirname(fileURLToPath(import.meta.url))
const workspace_root = path.resolve(module_dirname, '..')
process.stderr.write(`[speckit-cli-mcp] cwd=${process.cwd()} workspace_root=${workspace_root}\n`)
const send_json = (payload) => {
process.stdout.write(`${JSON.stringify(payload)}\n`)
}
const send_result = (id, result) => {
send_json({ jsonrpc: '2.0', id, result })
}
const send_error = (id, code, message, data) => {
const error = { code, message }
if (data !== undefined) {
error.data = data
}
send_json({ jsonrpc: '2.0', id, error })
}
const as_text_content = (obj) => {
return {
content: [
{
type: 'text',
text: JSON.stringify(obj, null, 4),
},
],
isError: obj.code !== 1,
}
}
const normalize_subcommand = (subcommand) => {
if (typeof subcommand !== 'string') {
return null
}
const trimmed = subcommand.trim()
if (!trimmed) {
return null
}
return trimmed
}
const validate_args = (args) => {
if (args === undefined) {
return { ok: true, value: [] }
}
if (!Array.isArray(args)) {
return { ok: false, message: 'args 必须为字符串数组' }
}
for (const item of args) {
if (typeof item !== 'string') {
return { ok: false, message: 'args 必须为字符串数组' }
}
if (item.length > 200) {
return { ok: false, message: 'args 单项长度不能超过 200' }
}
}
return { ok: true, value: args }
}
const resolve_cwd = (cwd_input) => {
if (!cwd_input) {
return workspace_root
}
if (typeof cwd_input !== 'string') {
return null
}
const resolved = path.resolve(workspace_root, cwd_input)
if (!resolved.startsWith(workspace_root)) {
return null
}
return resolved
}
const run_specify = async ({ cli, subcommand, args, cwd, timeout_ms }) => {
const cli_bin = cli === 'speckit' ? 'speckit' : 'specify'
const allowed_subcommands = new Set(['check', 'init'])
const normalized_subcommand = normalize_subcommand(subcommand)
if (!normalized_subcommand || !allowed_subcommands.has(normalized_subcommand)) {
return { code: 0, data: null, msg: `不支持的子命令:${subcommand}` }
}
const validated_args = validate_args(args)
if (!validated_args.ok) {
return { code: 0, data: null, msg: validated_args.message }
}
const resolved_cwd = resolve_cwd(cwd)
if (!resolved_cwd) {
return { code: 0, data: null, msg: 'cwd 非法,必须为工作区内的相对路径' }
}
const final_timeout_ms = Number.isFinite(timeout_ms) ? Math.max(1000, timeout_ms) : 10 * 60 * 1000
const final_args = [normalized_subcommand, ...validated_args.value]
return await new Promise((resolve) => {
const child = spawn(cli_bin, final_args, {
cwd: resolved_cwd,
env: process.env,
})
let stdout = ''
let stderr = ''
let timed_out = false
const timer = setTimeout(() => {
timed_out = true
child.kill('SIGTERM')
}, final_timeout_ms)
child.stdout.on('data', (chunk) => {
stdout += chunk.toString()
if (stdout.length > 2_000_000) {
stdout = `${stdout.slice(0, 2_000_000)}\n...[stdout 截断]`
}
})
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
if (stderr.length > 2_000_000) {
stderr = `${stderr.slice(0, 2_000_000)}\n...[stderr 截断]`
}
})
child.on('error', (err) => {
clearTimeout(timer)
resolve({
code: 0,
data: {
cwd: resolved_cwd,
cli: cli_bin,
args: final_args,
stdout,
stderr,
error: String(err?.message || err),
},
msg: '命令启动失败(请确认已安装 specify/speckit)',
})
})
child.on('close', (exit_code, signal) => {
clearTimeout(timer)
resolve({
code: exit_code === 0 && !timed_out ? 1 : 0,
data: {
cwd: resolved_cwd,
cli: cli_bin,
args: final_args,
exit_code,
signal,
timed_out,
stdout,
stderr,
},
msg: exit_code === 0 && !timed_out ? 'ok' : '命令执行失败',
})
})
})
}
const list_tools = () => {
return [
{
name: 'speckit_check',
description: '运行 specify check(检查工具链是否齐全)',
inputSchema: {
type: 'object',
properties: {
cli: { type: 'string', enum: ['specify', 'speckit'], default: 'specify' },
cwd: { type: 'string', description: '相对工作区路径(可选)' },
timeout_ms: { type: 'number', description: '超时时间(毫秒,可选)' },
},
required: [],
},
},
{
name: 'speckit_init_here',
description: '运行 specify init --here(初始化/更新 Spec Kit 文件)',
inputSchema: {
type: 'object',
properties: {
cli: { type: 'string', enum: ['specify', 'speckit'], default: 'specify' },
ai: { type: 'string', default: 'copilot' },
force: { type: 'boolean', default: false },
cwd: { type: 'string', description: '相对工作区路径(可选)' },
timeout_ms: { type: 'number', description: '超时时间(毫秒,可选)' },
},
required: [],
},
},
{
name: 'speckit_run',
description: '运行 allowlist 内的 specify 子命令(check/init)',
inputSchema: {
type: 'object',
properties: {
cli: { type: 'string', enum: ['specify', 'speckit'], default: 'specify' },
subcommand: { type: 'string', enum: ['check', 'init'] },
args: { type: 'array', items: { type: 'string' } },
cwd: { type: 'string', description: '相对工作区路径(可选)' },
timeout_ms: { type: 'number', description: '超时时间(毫秒,可选)' },
},
required: ['subcommand'],
},
},
]
}
const handle_request = async (msg) => {
const { id, method, params } = msg || {}
if (method === 'initialize') {
const requested = params?.protocolVersion
const negotiated = PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0]
send_result(id, {
protocolVersion: negotiated,
capabilities: {
tools: { listChanged: false },
},
serverInfo: server_info,
instructions: '仅提供 Speckit/Spec Kit CLI 的最小封装工具(check/init)。',
})
return
}
if (method === 'ping') {
send_result(id, {})
return
}
if (method === 'tools/list') {
send_result(id, { tools: list_tools() })
return
}
if (method === 'tools/call') {
const name = params?.name
const args = params?.arguments || {}
if (name === 'speckit_check') {
const res = await run_specify({
cli: args.cli,
subcommand: 'check',
args: [],
cwd: args.cwd,
timeout_ms: args.timeout_ms,
})
send_result(id, as_text_content(res))
return
}
if (name === 'speckit_init_here') {
const force_flag = args.force ? ['--force'] : []
const ai_value = typeof args.ai === 'string' && args.ai.trim() ? args.ai.trim() : 'copilot'
const res = await run_specify({
cli: args.cli,
subcommand: 'init',
args: ['--here', '--ai', ai_value, ...force_flag],
cwd: args.cwd,
timeout_ms: args.timeout_ms,
})
send_result(id, as_text_content(res))
return
}
if (name === 'speckit_run') {
const res = await run_specify({
cli: args.cli,
subcommand: args.subcommand,
args: args.args,
cwd: args.cwd,
timeout_ms: args.timeout_ms,
})
send_result(id, as_text_content(res))
return
}
send_error(id, -32602, `Unknown tool: ${name}`)
return
}
if (id !== undefined) {
send_error(id, -32601, `Method not found: ${method}`)
}
}
let buffer = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', async (chunk) => {
buffer += chunk
while (buffer.indexOf('\n') !== -1) {
const idx = buffer.indexOf('\n')
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line) {
continue
}
let msg
try {
msg = JSON.parse(line)
} catch (e) {
continue
}
await handle_request(msg)
}
})
process.stdin.on('end', () => {
process.exit(0)
})
......@@ -8,6 +8,7 @@
"build": ". ~/.nvm/nvm.sh && nvm use 18.19.1 && vite build",
"preview": "vite preview",
"test": "vitest run",
"mcp:speckit": "node mcp/speckit_stdio_server.js",
"tar": "tar -czvpf dist.tar.gz mlaj",
"build_tar": "npm run build && npm run tar",
"scp-dev": "scp dist.tar.gz ipadbiz-inner:/opt/space-dev/f",
......
# 001-recall-activity-history-state - 活动历史页空状态与加载失败提示
## 1) 背景与目标
- 背景:
- 当前“活动历史”页面会在进入时请求活动列表数据并渲染列表。
- 当接口返回无数据或请求失败时,页面缺少清晰的页面内反馈,用户难以判断是“没有活动记录”还是“加载失败”。
- 目标:
- 在不改接口、不新增接口的前提下,为活动历史页补齐“空状态提示”和“加载失败提示 + 重试入口”,提升可理解性与可恢复性。
- 非目标:
- 不调整接口入参、返回结构或后端逻辑。
- 不改动页面既有的顶部 Banner、底部按钮与“没找到我的星球活动”弹窗等核心结构与入口。
- 不引入新的页面路由与新的业务流程。
## 2) 用户画像与使用场景
- 已登录/已完成必要信息的用户,通过召回链路进入活动历史页,查看历史参与记录。
- 用户可能出现以下情况:
- 确实没有参与过活动(应看到“暂无活动记录”的空状态)。
- 网络不稳定、服务异常、参数缺失导致请求失败(应看到“加载失败”的错误提示,并可重试)。
## 3) 需求范围
- 页面/入口:
- 活动历史页(现有页面入口保持不变)。
- 核心流程:
- 页面进入后发起列表加载。
- 加载成功:
- 有数据:展示列表。
- 无数据:展示空状态。
- 加载失败:展示失败提示与重试按钮。
- 状态与异常分支:
- 请求失败(返回 code 不为 1 或发生异常)时进入失败状态。
- 重试成功后根据数据展示列表或空状态。
## 4) 用户故事(User Stories)
- 作为用户,我想在没有活动记录时看到明确提示,以便确认自己没有缺失记录。
- 作为用户,我想在列表加载失败时看到错误提示并能重试,以便在网络恢复后继续查看记录。
## 5) 验收标准(Acceptance Criteria)
- 有数据时:
- 页面展示活动列表。
- 不展示空状态与失败状态提示。
- 无数据时:
- 当列表加载已完成且列表为空时,页面在列表区域展示空状态提示文案(例如“暂无活动记录”)。
- 空状态为页面内可见提示,不以 Toast 作为唯一反馈。
- 加载失败时:
- 当列表请求失败(code!=1 或发生异常)时,页面在列表区域展示失败状态提示文案(例如“加载失败,请稍后重试”)。
- 失败提示区域提供“重试”按钮;点击后会重新发起列表请求。
- 重试成功后:
- 有数据则展示列表;
- 无数据则展示空状态;
- 不再展示失败提示。
- 结构与入口:
- 顶部 Banner、底部按钮、“没找到我的星球活动”入口与弹窗行为不受影响,保持可用。
## 6) 交互与视觉要点
- 空状态与失败状态展示位置:
- 在列表区域内展示,避免遮挡顶部 Banner 和底部固定按钮。
- 可读性:
- 文案简洁、明确区分“无数据”与“加载失败”。
- 一致性:
- 视觉风格与页面现有样式保持一致(背景、圆角卡片、间距等)。
## 7) 数据与接口
- 不新增接口、不修改接口。
- 数据来源仍为现有活动列表查询接口。
- 返回结构固定为 { code, data, msg }:
- code=1 视为成功;
- code!=1 视为失败;
- msg 可作为失败提示文案来源之一(若为空则使用默认失败文案)。
## 8) 边界条件与风险
- 缓存参数缺失或不完整可能导致请求失败,应进入失败状态并可重试。
- 网络波动可能导致首次失败、重试成功,状态切换需要正确更新页面展示。
## 9) 待确认事项
- [NEEDS CLARIFICATION] 失败状态文案是否需要优先展示接口返回 msg?若 msg 为空是否统一使用默认文案?
- [NEEDS CLARIFICATION] 空状态文案是否固定为“暂无活动记录”,还是需要根据场景区分(例如“未查询到您的历史活动”)?
## 步骤 0:选一个 feature_key
建议用:
- 001-recall-activity-history-state
## 步骤 1:/speckit.specify(生成 spec)
在 Trae 的 Copilot Chat 输入:
```
/speckit.specify 
001-recall-activity-history-state 
ActivityHistoryPage 增加一个空状态提示 + 列表加
载失败提示(不改接口)
背景:
- 页面文件:src/views/recall/
ActivityHistoryPage.vue
- 当前页面仅在 onMounted 调用 
searchOldActivityAPI 获取 activities,没有 
loading/empty/error 视图
- 接口返回结构为 { code, data, msg },不修改接口
与入参
需求:
1) 空状态:当活动列表加载完成且无数据
(activities 为空)时,展示页面内空状态提示(不要 
toast 代替)。
2) 加载失败:当 searchOldActivityAPI 请求失败
(code!=1 或抛异常)时,展示页面内错误提示,并提供
“重试”按钮;点击重试会重新发起列表请求。
3) 不影响页面现有结构:顶部 Banner、底部按钮、"没
找到我的星球活动" 弹窗等保持不变。
4) 样式:项目已引入 TailwindCSS,布局优先用 
Tailwind 类;必要补充用 Less(保持现有文件风
格)。
5) 不新增接口、不改接口返回结构。
验收:
- 正常有数据:列表正常展示,不显示空/错。
- 无数据:显示空状态文案(建议:暂无活动记录)。
- 请求失败:显示失败状态文案(建议:加载失败,请稍后
重试),点击重试后如果成功则显示列表/空状态。
```
预期产物:
- specs/001-recall-activity-history-state/spec.md
## 步骤 2:处理 [NEEDS CLARIFICATION](如果 Speckit 问你)
它可能会问这些,你直接用下面“推荐回答”贴回去:
- Q:失败判断是什么? A: searchOldActivityAPI 返回 code!==1 或请求抛异常都视为失败。
- Q:失败状态是否要展示 msg? A:优先展示接口 msg (若为空则用“加载失败,请稍后重试”)。
- Q:空状态是整页替换还是列表区域内? A:在“列表区域”显示空状态,不遮挡顶部 Banner 与底部按钮。
- Q:是否需要 loading 骨架/加载中? A:本次只做空状态与失败提示,不额外做骨架;但允许加一个轻量 loading 状态避免闪烁(可选,不作为验收点)。
## 步骤 3:/speckit.plan(生成 plan)
在 Copilot Chat 输入:
```
/speckit.plan 
001-recall-activity-history-state
技术约束:
- Vue3 <script setup>,不引入新库
- 仅修改 ActivityHistoryPage.vue(如确有必要允
许新增一个小的复用组件,但优先不新增文件)
- 不启动/不重启服务,不打开预览
```
预期产物:
- specs/001-recall-activity-history-state/plan.md
## 步骤 4:/speckit.tasks(生成 tasks)
在 Copilot Chat 输入:
```
/speckit.tasks 
001-recall-activity-history-state
```
预期产物:
- specs/001-recall-activity-history-state/tasks.md
## 步骤 5:/speckit.implement(按任务落地改代码)
在 Copilot Chat 输入:
```
/speckit.implement 
001-recall-activity-history-state
```
你可以在实现阶段额外强调一句(避免它越界):
- “只改 ActivityHistoryPage.vue,不要改接口,不要新增服务端逻辑,不要打开预览,不要重启任何服务。”
## 步骤 6:自检(不启动服务)
实现完成后只跑已有测试即可:
```
pnpm test
```
## 你改动点会落在哪里(便于你 review)
Speckit 最终大概率会在这个文件里做这些事(都不改接口):
- ActivityHistoryPage.vue
- 增加 loading / load_error / error_msg 之类的状态
- 把 onMounted 里的拉取逻辑抽成 fetch_activities (带 try/catch)
- 模板里在 List 区域根据状态展示:
- 列表
- 空状态
- 失败状态 + 重试按钮
如果你愿意,我也可以直接给你一份“更硬核的 /speckit.specify 文案”(把每条验收标准写成更可验证的条件),这样后面 plan/tasks/implement 的输出会更稳定。
This diff is collapsed. Click to expand it.