gsd-lite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/mcp.json +8 -0
- package/.claude-plugin/plugin.json +17 -0
- package/README.md +145 -0
- package/agents/gsd-debugger.md +92 -0
- package/agents/gsd-executor.md +86 -0
- package/agents/gsd-researcher.md +41 -0
- package/agents/gsd-reviewer.md +127 -0
- package/cli.js +37 -0
- package/commands/gsd-prd.md +154 -0
- package/commands/gsd-resume.md +216 -0
- package/commands/gsd-start.md +317 -0
- package/commands/gsd-status.md +114 -0
- package/commands/gsd-stop.md +50 -0
- package/hooks/context-monitor.js +64 -0
- package/hooks/hooks.json +19 -0
- package/install.js +151 -0
- package/package.json +51 -0
- package/references/anti-rationalization-full.md +112 -0
- package/references/git-worktrees.md +77 -0
- package/references/questioning.md +103 -0
- package/references/testing-patterns.md +110 -0
- package/src/schema.js +471 -0
- package/src/server.js +240 -0
- package/src/tools/orchestrator.js +986 -0
- package/src/tools/state.js +926 -0
- package/src/tools/verify.js +89 -0
- package/src/utils.js +73 -0
- package/uninstall.js +85 -0
- package/workflows/debugging.md +187 -0
- package/workflows/deviation-rules.md +128 -0
- package/workflows/research.md +139 -0
- package/workflows/review-cycle.md +153 -0
- package/workflows/tdd-cycle.md +154 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# 提问技巧参考 (Questioning Reference)
|
|
2
|
+
|
|
3
|
+
> 本文档供编排器在需求讨论阶段 (`/gsd:start` STEP 1) 使用。
|
|
4
|
+
> 目标: 通过结构化提问,将模糊需求转化为可执行规格。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 提问类型
|
|
9
|
+
|
|
10
|
+
### 开放式问题 (Open-ended)
|
|
11
|
+
用于初始探索,让用户自由表达意图。
|
|
12
|
+
|
|
13
|
+
| 场景 | 模板 |
|
|
14
|
+
|------|------|
|
|
15
|
+
| 项目启动 | "你想做什么?解决什么问题?" |
|
|
16
|
+
| 功能探索 | "这个功能的理想使用场景是什么?" |
|
|
17
|
+
| 约束发现 | "有没有什么限制或偏好需要考虑?" |
|
|
18
|
+
|
|
19
|
+
### 封闭式问题 (Closed)
|
|
20
|
+
用于确认具体决策,减少歧义。每个选项附简短理由,推荐项标 ⭐。
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
技术栈选择:
|
|
24
|
+
- A) Next.js App Router ⭐ — 全栈 + SSR,适合大多数 Web 项目
|
|
25
|
+
- B) Vite + React — 纯 SPA,适合不需要 SSR 的场景
|
|
26
|
+
- C) 其他 — 请说明
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. 消除模糊的技巧
|
|
32
|
+
|
|
33
|
+
### 2.1 具象化 (Concretize)
|
|
34
|
+
把抽象需求转为具体场景。
|
|
35
|
+
|
|
36
|
+
| 模糊表述 | 追问 |
|
|
37
|
+
|----------|------|
|
|
38
|
+
| "要快" | "目标响应时间是多少? 200ms? 1s?" |
|
|
39
|
+
| "要安全" | "需要哪些安全措施?认证? 授权? 数据加密?" |
|
|
40
|
+
| "用户管理" | "用户的核心操作有哪些?注册/登录/角色/权限?" |
|
|
41
|
+
|
|
42
|
+
### 2.2 边界探测 (Boundary Probing)
|
|
43
|
+
- "最多支持多少用户/数据量?"
|
|
44
|
+
- "离线场景需要考虑吗?"
|
|
45
|
+
- "需要支持哪些浏览器/设备?"
|
|
46
|
+
|
|
47
|
+
### 2.3 挑战假设 (Challenge Assumptions)
|
|
48
|
+
- "你提到 X,是因为 Y 吗?还是有其他原因?"
|
|
49
|
+
- "这个功能一定需要实时吗?异步处理是否可接受?"
|
|
50
|
+
- "是否考虑过更简单的方案?"
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 3. 何时追问 vs 何时推进
|
|
55
|
+
|
|
56
|
+
### 必须追问
|
|
57
|
+
- 用户需求存在歧义 (同一表述有多种合理解读)
|
|
58
|
+
- 架构级决策缺失 (数据库/认证/部署方案未确定)
|
|
59
|
+
- 存在相互矛盾的需求
|
|
60
|
+
|
|
61
|
+
### 可以推进 (无需追问)
|
|
62
|
+
- 决策属于实现细节 (文件命名、内部函数设计)
|
|
63
|
+
- 有明确的行业最佳实践
|
|
64
|
+
- 用户已明确表达偏好
|
|
65
|
+
- → 推进时用 `[DECISION]` 标注你的选择和理由
|
|
66
|
+
|
|
67
|
+
### 判断规则
|
|
68
|
+
```
|
|
69
|
+
该决策是否影响用户可见的行为?
|
|
70
|
+
├── 是 → 追问
|
|
71
|
+
└── 否 → 该决策是否可逆?
|
|
72
|
+
├── 是 → 合理选择 + [DECISION] 标注
|
|
73
|
+
└── 否 → 追问
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 4. 推荐选项标识 ⭐
|
|
79
|
+
|
|
80
|
+
提供选项时: ⭐ 标识推荐项 + 附简短理由 | 无明确推荐 → 说明 trade-off
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 5. 常见场景问题模板
|
|
85
|
+
|
|
86
|
+
| 场景 | 问题示例 |
|
|
87
|
+
|------|---------|
|
|
88
|
+
| 技术栈 | "目标平台?" / "现有代码库或偏好?" / "团队最熟悉什么?" |
|
|
89
|
+
| 架构 | "数据关系复杂吗?" / "需要实时更新?" / "预期用户规模?" |
|
|
90
|
+
| 范围 | "MVP 包含哪些功能?" / "有 deadline 吗?" / "需要外部集成?" |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 6. 提问节奏
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
轮次 1: 开放式 — 理解意图和背景
|
|
98
|
+
轮次 2: 封闭式 — 确认关键决策 (技术栈/架构/范围)
|
|
99
|
+
轮次 3: 边界 — 探测约束和非功能需求
|
|
100
|
+
轮次 4: 确认 — 总结理解,用户确认
|
|
101
|
+
→ 大多数项目 2-4 轮即可完成需求收集
|
|
102
|
+
→ 避免过度追问: 每轮最多 3-5 个问题
|
|
103
|
+
```
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# 测试模式与覆盖率指南 (Testing Patterns Reference)
|
|
2
|
+
|
|
3
|
+
> 本文档供 executor 在编写测试时参考。
|
|
4
|
+
> 核心原则: 测试的目的是 **证明行为正确**,不是追求覆盖率数字。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. TDD 循环: RED-GREEN-REFACTOR
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
RED → 写一个失败的测试 (定义期望行为)
|
|
12
|
+
GREEN → 写最小代码让测试通过 (不要多写)
|
|
13
|
+
REFACTOR → 清理代码,保持测试通过 → 重复
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **RED:** 测试必须真的失败。立刻通过 → 测试有问题或功能已存在。
|
|
17
|
+
- **GREEN:** 只写让测试通过的最小代码。不要提前优化。
|
|
18
|
+
- **REFACTOR:** 只重构不加功能。测试必须持续通过。
|
|
19
|
+
|
|
20
|
+
### TDD 例外 (不需要先写失败测试)
|
|
21
|
+
配置文件 | CSS/样式 | 数据库迁移 | 纯文档 | CI/CD | 环境变量/部署配置
|
|
22
|
+
→ 改为: 实现 → 验证生效 → checkpoint commit
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 2. 测试粒度
|
|
27
|
+
|
|
28
|
+
| 层级 | 范围 | 速度 | 占比 | 何时用 |
|
|
29
|
+
|------|------|------|------|--------|
|
|
30
|
+
| Unit | 单个函数/模块 | 快 (ms) | ~70% | 纯逻辑、工具函数、数据转换 |
|
|
31
|
+
| Integration | 模块间交互 | 中 (s) | ~20% | API 端点、数据库操作、服务间调用 |
|
|
32
|
+
| E2E | 完整用户流程 | 慢 (10s+) | ~10% | 关键路径、支付流程、认证流程 |
|
|
33
|
+
|
|
34
|
+
### 粒度选择规则
|
|
35
|
+
```
|
|
36
|
+
该功能是纯计算/转换? → Unit
|
|
37
|
+
该功能涉及多个模块协作? → Integration
|
|
38
|
+
该功能是用户可见的完整流程? → E2E
|
|
39
|
+
拿不准? → 从 Unit 开始,必要时补 Integration
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 3. 测什么 vs 不测什么
|
|
45
|
+
|
|
46
|
+
### 应该测试
|
|
47
|
+
- 业务逻辑 (计算、规则、状态转换)
|
|
48
|
+
- 边界条件 (空输入、极值、边界值)
|
|
49
|
+
- 错误路径 (异常处理、错误消息、降级行为)
|
|
50
|
+
- 公共 API 契约 (输入/输出/错误码)
|
|
51
|
+
- 状态变更 (数据库写入、文件系统操作)
|
|
52
|
+
|
|
53
|
+
### 不应该测试
|
|
54
|
+
- 框架/库的内部行为 (React 渲染机制、Express 路由匹配)
|
|
55
|
+
- 第三方 API 的具体实现 (mock 它)
|
|
56
|
+
- 私有方法的实现细节 (通过公共接口间接测试)
|
|
57
|
+
- 简单的 getter/setter (除非有逻辑)
|
|
58
|
+
- 常量和配置值 (除非有验证逻辑)
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 4. Mock/Stub 策略
|
|
63
|
+
|
|
64
|
+
| 场景 | 策略 |
|
|
65
|
+
|------|------|
|
|
66
|
+
| 外部服务 (HTTP/DB/文件系统) | Unit 测试中 mock |
|
|
67
|
+
| 时间/随机数 | mock `Date.now()` / `Math.random()` |
|
|
68
|
+
| Integration 测试 | 尽量用真实依赖 (test DB, in-memory store) |
|
|
69
|
+
| 模块内部协作 | 不 mock 自己的代码 |
|
|
70
|
+
|
|
71
|
+
原则: Mock 外部边界,不 Mock 内部逻辑。Mock 数量 ≤ 3/文件,超过则考虑重构。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 5. 测试命名规范
|
|
76
|
+
|
|
77
|
+
格式: `describe('<被测模块>') → it('should <期望行为> when <条件>')`
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
describe('UserService')
|
|
81
|
+
it('should return user when valid ID is provided')
|
|
82
|
+
it('should throw NotFoundError when user does not exist')
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
规则: 描述行为不描述实现 | 包含条件和期望结果 | 用业务语言
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 6. 常见测试反模式
|
|
90
|
+
|
|
91
|
+
| 反模式 | 正确做法 |
|
|
92
|
+
|--------|---------|
|
|
93
|
+
| 测试间共享可变状态 | `beforeEach` 重置状态 |
|
|
94
|
+
| 测试实现细节 (重构就破) | 只测公共行为和输出 |
|
|
95
|
+
| 忽略异步错误 (假通过) | 正确 `await` / 验证 rejection |
|
|
96
|
+
| 只测 happy path | 为每个错误场景写测试 |
|
|
97
|
+
| Snapshot 滥用 | 只在 UI 渲染稳定后使用 |
|
|
98
|
+
| 测试中有逻辑 (if/loop) | 测试应线性: 准备 → 执行 → 断言 |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 7. 覆盖率指南
|
|
103
|
+
|
|
104
|
+
| 代码类别 | 目标 | 示例 |
|
|
105
|
+
|----------|------|------|
|
|
106
|
+
| 关键路径 | 100% | 认证/支付/数据持久化/核心业务规则 |
|
|
107
|
+
| 一般代码 | ≥ 80% | API 端点/工具函数/状态管理 |
|
|
108
|
+
| 低风险代码 | ≥ 60% | UI 组件 (用 E2E 补充)/配置/日志 |
|
|
109
|
+
|
|
110
|
+
注意: 覆盖率 ≠ 质量。关注有意义的断言,不要为追求数字写低质量测试。
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// State schema + lifecycle validation
|
|
2
|
+
|
|
3
|
+
export const WORKFLOW_MODES = [
|
|
4
|
+
'planning',
|
|
5
|
+
'executing_task',
|
|
6
|
+
'reviewing_task',
|
|
7
|
+
'reviewing_phase',
|
|
8
|
+
'awaiting_clear',
|
|
9
|
+
'awaiting_user',
|
|
10
|
+
'paused_by_user',
|
|
11
|
+
'reconcile_workspace',
|
|
12
|
+
'replan_required',
|
|
13
|
+
'research_refresh_needed',
|
|
14
|
+
'completed',
|
|
15
|
+
'failed',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TASK_LIFECYCLE = {
|
|
19
|
+
pending: ['running', 'blocked'],
|
|
20
|
+
running: ['checkpointed', 'blocked', 'failed'],
|
|
21
|
+
checkpointed: ['accepted', 'needs_revalidation'],
|
|
22
|
+
accepted: ['needs_revalidation'],
|
|
23
|
+
blocked: ['pending'],
|
|
24
|
+
failed: [],
|
|
25
|
+
needs_revalidation: ['pending'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const PHASE_LIFECYCLE = {
|
|
29
|
+
pending: ['active'],
|
|
30
|
+
active: ['reviewing', 'blocked', 'failed'],
|
|
31
|
+
reviewing: ['accepted', 'active'],
|
|
32
|
+
accepted: [],
|
|
33
|
+
blocked: ['active'],
|
|
34
|
+
failed: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const PHASE_REVIEW_STATUS = ['pending', 'reviewing', 'accepted', 'rework_required'];
|
|
38
|
+
|
|
39
|
+
export const CANONICAL_FIELDS = [
|
|
40
|
+
'project',
|
|
41
|
+
'workflow_mode',
|
|
42
|
+
'plan_version',
|
|
43
|
+
'git_head',
|
|
44
|
+
'current_phase',
|
|
45
|
+
'current_task',
|
|
46
|
+
'current_review',
|
|
47
|
+
'total_phases',
|
|
48
|
+
'phases',
|
|
49
|
+
'decisions',
|
|
50
|
+
'context',
|
|
51
|
+
'research',
|
|
52
|
+
'evidence',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function isPlainObject(value) {
|
|
56
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateResearchSourcesArray(sources, errors, path = 'sources') {
|
|
60
|
+
if (!Array.isArray(sources)) {
|
|
61
|
+
errors.push(`${path} must be array`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const source of sources) {
|
|
66
|
+
if (!isPlainObject(source)) {
|
|
67
|
+
errors.push(`${path} entries must be objects`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (typeof source.id !== 'string' || source.id.length === 0) errors.push(`${path}[].id must be non-empty string`);
|
|
71
|
+
if (typeof source.type !== 'string' || source.type.length === 0) errors.push(`${path}[].type must be non-empty string`);
|
|
72
|
+
if (typeof source.ref !== 'string' || source.ref.length === 0) errors.push(`${path}[].ref must be non-empty string`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateResearchDecisionIndex(decisionIndex, requiredIds = []) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
if (!isPlainObject(decisionIndex)) {
|
|
79
|
+
errors.push('decision_index must be an object');
|
|
80
|
+
return { valid: false, errors };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const id of requiredIds) {
|
|
84
|
+
if (!isPlainObject(decisionIndex[id])) {
|
|
85
|
+
errors.push(`decision_index.${id} must be an object`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [id, entry] of Object.entries(decisionIndex)) {
|
|
90
|
+
if (!isPlainObject(entry)) {
|
|
91
|
+
errors.push(`decision_index.${id} must be an object`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (typeof entry.summary !== 'string' || entry.summary.length === 0) {
|
|
95
|
+
errors.push(`decision_index.${id}.summary must be a non-empty string`);
|
|
96
|
+
}
|
|
97
|
+
if ('source' in entry && (typeof entry.source !== 'string' || entry.source.length === 0)) {
|
|
98
|
+
errors.push(`decision_index.${id}.source must be a non-empty string`);
|
|
99
|
+
}
|
|
100
|
+
if ('expires_at' in entry && (typeof entry.expires_at !== 'string' || entry.expires_at.length === 0)) {
|
|
101
|
+
errors.push(`decision_index.${id}.expires_at must be a non-empty string`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { valid: errors.length === 0, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function validateResearchArtifacts(artifacts, { decisionIds = [], volatility, expiresAt } = {}) {
|
|
109
|
+
const errors = [];
|
|
110
|
+
if (!isPlainObject(artifacts)) {
|
|
111
|
+
return { valid: false, errors: ['artifacts must be an object'] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const requiredFiles = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
|
|
115
|
+
for (const fileName of requiredFiles) {
|
|
116
|
+
if (typeof artifacts[fileName] !== 'string' || artifacts[fileName].trim().length === 0) {
|
|
117
|
+
errors.push(`artifacts.${fileName} must be a non-empty string`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const summary = typeof artifacts['SUMMARY.md'] === 'string' ? artifacts['SUMMARY.md'] : '';
|
|
122
|
+
if (volatility && !summary.includes(volatility)) {
|
|
123
|
+
errors.push('artifacts.SUMMARY.md must mention volatility');
|
|
124
|
+
}
|
|
125
|
+
if (expiresAt && !summary.includes(expiresAt)) {
|
|
126
|
+
errors.push('artifacts.SUMMARY.md must mention expires_at');
|
|
127
|
+
}
|
|
128
|
+
for (const id of decisionIds) {
|
|
129
|
+
if (!summary.includes(id)) {
|
|
130
|
+
errors.push(`artifacts.SUMMARY.md must mention decision id ${id}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { valid: errors.length === 0, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function validateTransition(entity, from, to) {
|
|
138
|
+
const transitions = entity === 'task' ? TASK_LIFECYCLE : PHASE_LIFECYCLE;
|
|
139
|
+
if (!transitions[from]) {
|
|
140
|
+
return { valid: false, error: `Unknown ${entity} state: ${from}` };
|
|
141
|
+
}
|
|
142
|
+
if (!transitions[from].includes(to)) {
|
|
143
|
+
return { valid: false, error: `Invalid ${entity} transition: ${from} → ${to}` };
|
|
144
|
+
}
|
|
145
|
+
return { valid: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function validateState(state) {
|
|
149
|
+
const errors = [];
|
|
150
|
+
if (!state.project || typeof state.project !== 'string') {
|
|
151
|
+
errors.push('project must be a non-empty string');
|
|
152
|
+
}
|
|
153
|
+
if (!WORKFLOW_MODES.includes(state.workflow_mode)) {
|
|
154
|
+
errors.push(`Invalid workflow_mode: ${state.workflow_mode}`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof state.plan_version !== 'number') {
|
|
157
|
+
errors.push('plan_version must be a number');
|
|
158
|
+
}
|
|
159
|
+
if (typeof state.current_phase !== 'number') {
|
|
160
|
+
errors.push('current_phase must be a number');
|
|
161
|
+
}
|
|
162
|
+
if (state.git_head !== null && typeof state.git_head !== 'string') {
|
|
163
|
+
errors.push('git_head must be a string or null');
|
|
164
|
+
}
|
|
165
|
+
if (typeof state.total_phases !== 'number') {
|
|
166
|
+
errors.push('total_phases must be a number');
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(state.phases)) {
|
|
169
|
+
errors.push('phases must be an array');
|
|
170
|
+
}
|
|
171
|
+
if (!Array.isArray(state.decisions)) {
|
|
172
|
+
errors.push('decisions must be an array');
|
|
173
|
+
}
|
|
174
|
+
if (!isPlainObject(state.context)) {
|
|
175
|
+
errors.push('context must be an object');
|
|
176
|
+
} else {
|
|
177
|
+
if (typeof state.context.last_session !== 'string') {
|
|
178
|
+
errors.push('context.last_session must be a string');
|
|
179
|
+
}
|
|
180
|
+
if (typeof state.context.remaining_percentage !== 'number') {
|
|
181
|
+
errors.push('context.remaining_percentage must be a number');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (state.research !== null && !isPlainObject(state.research)) {
|
|
185
|
+
errors.push('research must be null or an object');
|
|
186
|
+
}
|
|
187
|
+
if (isPlainObject(state.research) && 'decision_index' in state.research && !isPlainObject(state.research.decision_index)) {
|
|
188
|
+
errors.push('research.decision_index must be an object');
|
|
189
|
+
}
|
|
190
|
+
if (isPlainObject(state.research)) {
|
|
191
|
+
if ('volatility' in state.research && !['low', 'medium', 'high'].includes(state.research.volatility)) {
|
|
192
|
+
errors.push('research.volatility must be low|medium|high');
|
|
193
|
+
}
|
|
194
|
+
if ('expires_at' in state.research
|
|
195
|
+
&& (typeof state.research.expires_at !== 'string' || state.research.expires_at.length === 0)) {
|
|
196
|
+
errors.push('research.expires_at must be a non-empty string');
|
|
197
|
+
}
|
|
198
|
+
if ('files' in state.research && !Array.isArray(state.research.files)) {
|
|
199
|
+
errors.push('research.files must be an array');
|
|
200
|
+
}
|
|
201
|
+
if (Array.isArray(state.research.files)) {
|
|
202
|
+
for (const fileName of state.research.files) {
|
|
203
|
+
if (typeof fileName !== 'string' || fileName.length === 0) {
|
|
204
|
+
errors.push('research.files entries must be non-empty strings');
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if ('sources' in state.research) {
|
|
210
|
+
validateResearchSourcesArray(state.research.sources, errors, 'research.sources');
|
|
211
|
+
}
|
|
212
|
+
if ('decision_index' in state.research) {
|
|
213
|
+
const decisionIndexValidation = validateResearchDecisionIndex(state.research.decision_index);
|
|
214
|
+
errors.push(...decisionIndexValidation.errors.map((error) => `research.${error}`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!isPlainObject(state.evidence)) {
|
|
218
|
+
errors.push('evidence must be an object');
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(state.phases)) {
|
|
221
|
+
if (typeof state.total_phases === 'number' && state.total_phases !== state.phases.length) {
|
|
222
|
+
errors.push(`total_phases (${state.total_phases}) does not match phases.length (${state.phases.length})`);
|
|
223
|
+
}
|
|
224
|
+
for (const phase of state.phases) {
|
|
225
|
+
if (!isPlainObject(phase)) {
|
|
226
|
+
errors.push('phase must be an object');
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (typeof phase.id !== 'number') {
|
|
230
|
+
errors.push('phase.id must be a number');
|
|
231
|
+
}
|
|
232
|
+
if (!phase.name || typeof phase.name !== 'string') {
|
|
233
|
+
errors.push(`Phase ${phase.id}: name must be a non-empty string`);
|
|
234
|
+
}
|
|
235
|
+
if (!PHASE_LIFECYCLE[phase.lifecycle]) {
|
|
236
|
+
errors.push(`Phase ${phase.id}: invalid lifecycle ${phase.lifecycle}`);
|
|
237
|
+
}
|
|
238
|
+
if (!isPlainObject(phase.phase_review)) {
|
|
239
|
+
errors.push(`Phase ${phase.id}: phase_review must be an object`);
|
|
240
|
+
} else {
|
|
241
|
+
if (!PHASE_REVIEW_STATUS.includes(phase.phase_review.status)) {
|
|
242
|
+
errors.push(`Phase ${phase.id}: invalid phase_review.status ${phase.phase_review.status}`);
|
|
243
|
+
}
|
|
244
|
+
if (typeof phase.phase_review.retry_count !== 'number') {
|
|
245
|
+
errors.push(`Phase ${phase.id}: phase_review.retry_count must be a number`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (typeof phase.tasks !== 'number') {
|
|
249
|
+
errors.push(`Phase ${phase.id}: tasks must be a number`);
|
|
250
|
+
}
|
|
251
|
+
if (typeof phase.done !== 'number') {
|
|
252
|
+
errors.push(`Phase ${phase.id}: done must be a number`);
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(phase.todo)) {
|
|
255
|
+
errors.push(`Phase ${phase.id}: todo must be an array`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!isPlainObject(phase.phase_handoff)) {
|
|
259
|
+
errors.push(`Phase ${phase.id}: phase_handoff must be an object`);
|
|
260
|
+
} else {
|
|
261
|
+
if (typeof phase.phase_handoff.required_reviews_passed !== 'boolean') {
|
|
262
|
+
errors.push(`Phase ${phase.id}: phase_handoff.required_reviews_passed must be boolean`);
|
|
263
|
+
}
|
|
264
|
+
if (typeof phase.phase_handoff.tests_passed !== 'boolean') {
|
|
265
|
+
errors.push(`Phase ${phase.id}: phase_handoff.tests_passed must be boolean`);
|
|
266
|
+
}
|
|
267
|
+
if (typeof phase.phase_handoff.critical_issues_open !== 'number') {
|
|
268
|
+
errors.push(`Phase ${phase.id}: phase_handoff.critical_issues_open must be a number`);
|
|
269
|
+
}
|
|
270
|
+
if ('direction_ok' in phase.phase_handoff && typeof phase.phase_handoff.direction_ok !== 'boolean') {
|
|
271
|
+
errors.push(`Phase ${phase.id}: phase_handoff.direction_ok must be boolean when present`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const task of phase.todo) {
|
|
275
|
+
if (!isPlainObject(task)) {
|
|
276
|
+
errors.push(`Phase ${phase.id}: task must be an object`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!task.id || typeof task.id !== 'string') {
|
|
280
|
+
errors.push('task.id must be a non-empty string');
|
|
281
|
+
}
|
|
282
|
+
if (!task.name || typeof task.name !== 'string') {
|
|
283
|
+
errors.push(`Task ${task.id}: name must be a non-empty string`);
|
|
284
|
+
}
|
|
285
|
+
if (!TASK_LIFECYCLE[task.lifecycle]) {
|
|
286
|
+
errors.push(`Task ${task.id}: invalid lifecycle ${task.lifecycle}`);
|
|
287
|
+
}
|
|
288
|
+
if (typeof task.level !== 'string') {
|
|
289
|
+
errors.push(`Task ${task.id}: level must be a string`);
|
|
290
|
+
}
|
|
291
|
+
if (!Array.isArray(task.requires)) {
|
|
292
|
+
errors.push(`Task ${task.id}: requires must be an array`);
|
|
293
|
+
}
|
|
294
|
+
if (typeof task.retry_count !== 'number') {
|
|
295
|
+
errors.push(`Task ${task.id}: retry_count must be a number`);
|
|
296
|
+
}
|
|
297
|
+
if (typeof task.review_required !== 'boolean') {
|
|
298
|
+
errors.push(`Task ${task.id}: review_required must be a boolean`);
|
|
299
|
+
}
|
|
300
|
+
if (typeof task.verification_required !== 'boolean') {
|
|
301
|
+
errors.push(`Task ${task.id}: verification_required must be a boolean`);
|
|
302
|
+
}
|
|
303
|
+
if (task.checkpoint_commit !== null && typeof task.checkpoint_commit !== 'string') {
|
|
304
|
+
errors.push(`Task ${task.id}: checkpoint_commit must be a string or null`);
|
|
305
|
+
}
|
|
306
|
+
if (!Array.isArray(task.research_basis)) {
|
|
307
|
+
errors.push(`Task ${task.id}: research_basis must be an array`);
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(task.evidence_refs)) {
|
|
310
|
+
errors.push(`Task ${task.id}: evidence_refs must be an array`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { valid: errors.length === 0, errors };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validate an executor result against the agent contract.
|
|
320
|
+
*/
|
|
321
|
+
export function validateExecutorResult(r) {
|
|
322
|
+
const errors = [];
|
|
323
|
+
if (!r.task_id) errors.push('missing task_id');
|
|
324
|
+
if (!['checkpointed', 'blocked', 'failed'].includes(r.outcome)) errors.push('invalid outcome');
|
|
325
|
+
if (typeof r.summary !== 'string' || r.summary.length === 0) errors.push('summary must be non-empty string');
|
|
326
|
+
if ('checkpoint_commit' in r && r.checkpoint_commit !== null && typeof r.checkpoint_commit !== 'string') {
|
|
327
|
+
errors.push('checkpoint_commit must be string or null');
|
|
328
|
+
}
|
|
329
|
+
if (!Array.isArray(r.files_changed)) errors.push('files_changed must be array');
|
|
330
|
+
if (!Array.isArray(r.decisions)) errors.push('decisions must be array');
|
|
331
|
+
if (!Array.isArray(r.blockers)) errors.push('blockers must be array');
|
|
332
|
+
if (typeof r.contract_changed !== 'boolean') errors.push('contract_changed must be boolean');
|
|
333
|
+
if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
|
|
334
|
+
if (r.outcome === 'checkpointed' && typeof r.checkpoint_commit !== 'string') {
|
|
335
|
+
errors.push('checkpointed outcome requires checkpoint_commit');
|
|
336
|
+
}
|
|
337
|
+
return { valid: errors.length === 0, errors };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Validate a reviewer result against the agent contract.
|
|
342
|
+
*/
|
|
343
|
+
export function validateReviewerResult(r) {
|
|
344
|
+
const errors = [];
|
|
345
|
+
if (!['task', 'phase'].includes(r.scope)) errors.push('invalid scope');
|
|
346
|
+
if (!(typeof r.scope_id === 'string' || typeof r.scope_id === 'number') || r.scope_id === '') {
|
|
347
|
+
errors.push('missing scope_id');
|
|
348
|
+
}
|
|
349
|
+
if (!['L2', 'L1-batch'].includes(r.review_level)) errors.push('invalid review_level');
|
|
350
|
+
if (typeof r.spec_passed !== 'boolean') errors.push('spec_passed must be boolean');
|
|
351
|
+
if (typeof r.quality_passed !== 'boolean') errors.push('quality_passed must be boolean');
|
|
352
|
+
if (!Array.isArray(r.critical_issues)) errors.push('critical_issues must be array');
|
|
353
|
+
if (!Array.isArray(r.important_issues)) errors.push('important_issues must be array');
|
|
354
|
+
if (!Array.isArray(r.minor_issues)) errors.push('minor_issues must be array');
|
|
355
|
+
if (!Array.isArray(r.accepted_tasks)) errors.push('accepted_tasks must be array');
|
|
356
|
+
if (!Array.isArray(r.rework_tasks)) errors.push('rework_tasks must be array');
|
|
357
|
+
if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
|
|
358
|
+
|
|
359
|
+
for (const issue of r.critical_issues || []) {
|
|
360
|
+
if (!isPlainObject(issue)) {
|
|
361
|
+
errors.push('critical_issues entries must be objects');
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (typeof issue.reason !== 'string' || issue.reason.length === 0) {
|
|
365
|
+
errors.push('critical_issues[].reason must be non-empty string');
|
|
366
|
+
}
|
|
367
|
+
if ('task_id' in issue && typeof issue.task_id !== 'string') {
|
|
368
|
+
errors.push('critical_issues[].task_id must be string');
|
|
369
|
+
}
|
|
370
|
+
if ('invalidates_downstream' in issue && typeof issue.invalidates_downstream !== 'boolean') {
|
|
371
|
+
errors.push('critical_issues[].invalidates_downstream must be boolean');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { valid: errors.length === 0, errors };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Validate a researcher result against the agent contract.
|
|
379
|
+
*/
|
|
380
|
+
export function validateResearcherResult(r) {
|
|
381
|
+
const errors = [];
|
|
382
|
+
if (!Array.isArray(r.decision_ids)) errors.push('decision_ids must be array');
|
|
383
|
+
if (!['low', 'medium', 'high'].includes(r.volatility)) errors.push('invalid volatility');
|
|
384
|
+
if (typeof r.expires_at !== 'string' || r.expires_at.length === 0) errors.push('missing expires_at');
|
|
385
|
+
validateResearchSourcesArray(r.sources, errors, 'sources');
|
|
386
|
+
|
|
387
|
+
return { valid: errors.length === 0, errors };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Validate a debugger result against the agent contract.
|
|
392
|
+
*/
|
|
393
|
+
export function validateDebuggerResult(r) {
|
|
394
|
+
const errors = [];
|
|
395
|
+
if (!r.task_id) errors.push('missing task_id');
|
|
396
|
+
if (!['root_cause_found', 'fix_suggested', 'failed'].includes(r.outcome)) errors.push('invalid outcome');
|
|
397
|
+
if (typeof r.root_cause !== 'string' || r.root_cause.length === 0) errors.push('root_cause must be non-empty string');
|
|
398
|
+
if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
|
|
399
|
+
if (!Array.isArray(r.hypothesis_tested)) errors.push('hypothesis_tested must be array');
|
|
400
|
+
if (typeof r.fix_direction !== 'string' || r.fix_direction.length === 0) errors.push('fix_direction must be non-empty string');
|
|
401
|
+
if (!Number.isInteger(r.fix_attempts) || r.fix_attempts < 0) errors.push('fix_attempts must be non-negative integer');
|
|
402
|
+
if (!Array.isArray(r.blockers)) errors.push('blockers must be array');
|
|
403
|
+
if (typeof r.architecture_concern !== 'boolean') errors.push('architecture_concern must be boolean');
|
|
404
|
+
if (r.fix_attempts >= 3 && r.outcome !== 'failed') errors.push('fix_attempts >= 3 requires failed outcome');
|
|
405
|
+
|
|
406
|
+
for (const hypothesis of r.hypothesis_tested || []) {
|
|
407
|
+
if (!isPlainObject(hypothesis)) {
|
|
408
|
+
errors.push('hypothesis_tested entries must be objects');
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (typeof hypothesis.hypothesis !== 'string' || hypothesis.hypothesis.length === 0) {
|
|
412
|
+
errors.push('hypothesis_tested[].hypothesis must be non-empty string');
|
|
413
|
+
}
|
|
414
|
+
if (!['confirmed', 'rejected'].includes(hypothesis.result)) {
|
|
415
|
+
errors.push('hypothesis_tested[].result must be confirmed or rejected');
|
|
416
|
+
}
|
|
417
|
+
if (typeof hypothesis.evidence !== 'string' || hypothesis.evidence.length === 0) {
|
|
418
|
+
errors.push('hypothesis_tested[].evidence must be non-empty string');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { valid: errors.length === 0, errors };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function createInitialState({ project, phases }) {
|
|
426
|
+
return {
|
|
427
|
+
project,
|
|
428
|
+
workflow_mode: 'executing_task',
|
|
429
|
+
plan_version: 1,
|
|
430
|
+
git_head: null,
|
|
431
|
+
current_phase: 1,
|
|
432
|
+
current_task: null,
|
|
433
|
+
current_review: null,
|
|
434
|
+
total_phases: phases.length,
|
|
435
|
+
phases: phases.map((p, i) => ({
|
|
436
|
+
id: i + 1,
|
|
437
|
+
name: p.name,
|
|
438
|
+
lifecycle: i === 0 ? 'active' : 'pending',
|
|
439
|
+
phase_review: { status: 'pending', retry_count: 0 },
|
|
440
|
+
tasks: p.tasks ? p.tasks.length : 0,
|
|
441
|
+
done: 0,
|
|
442
|
+
todo: (p.tasks || []).map((t, ti) => ({
|
|
443
|
+
id: `${i + 1}.${t.index || ti + 1}`,
|
|
444
|
+
name: t.name,
|
|
445
|
+
lifecycle: 'pending',
|
|
446
|
+
level: t.level || 'L1',
|
|
447
|
+
requires: t.requires || [],
|
|
448
|
+
retry_count: 0,
|
|
449
|
+
review_required: t.review_required !== false,
|
|
450
|
+
verification_required: t.verification_required !== false,
|
|
451
|
+
checkpoint_commit: null,
|
|
452
|
+
research_basis: t.research_basis || [],
|
|
453
|
+
evidence_refs: [],
|
|
454
|
+
...(t.blocked_reason ? { blocked_reason: t.blocked_reason } : {}),
|
|
455
|
+
...(t.invalidate_downstream_on_change ? { invalidate_downstream_on_change: true } : {}),
|
|
456
|
+
})),
|
|
457
|
+
phase_handoff: {
|
|
458
|
+
required_reviews_passed: false,
|
|
459
|
+
tests_passed: false,
|
|
460
|
+
critical_issues_open: 0,
|
|
461
|
+
},
|
|
462
|
+
})),
|
|
463
|
+
decisions: [],
|
|
464
|
+
context: {
|
|
465
|
+
last_session: new Date().toISOString(),
|
|
466
|
+
remaining_percentage: 100,
|
|
467
|
+
},
|
|
468
|
+
research: null,
|
|
469
|
+
evidence: {},
|
|
470
|
+
};
|
|
471
|
+
}
|