@boyingliu01/xp-gate 0.5.1 → 0.5.2

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/bin/xp-gate.js CHANGED
@@ -48,6 +48,11 @@ const COMMANDS = {
48
48
  description: 'Diagnose xp-gate installation health',
49
49
  fn: doctor,
50
50
  usage: 'xp-gate doctor [--fix]'
51
+ },
52
+ 'ui-review': {
53
+ description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
54
+ fn: null,
55
+ usage: 'xp-gate ui-review'
51
56
  }
52
57
  };
53
58
 
@@ -136,6 +141,18 @@ function main() {
136
141
  doctor(subargs).then(code => process.exit(code));
137
142
  return;
138
143
  }
144
+
145
+ if (command === 'ui-review') {
146
+ const { execSync } = require('child_process');
147
+ const path = require('path');
148
+ const uiReviewPath = path.join(__dirname, '..', 'lib', 'ui-review.ts');
149
+ try {
150
+ execSync(`npx -y tsx "${uiReviewPath}"`, { stdio: 'inherit' });
151
+ process.exit(0);
152
+ } catch (err) {
153
+ process.exit(1);
154
+ }
155
+ }
139
156
 
140
157
  console.error(`Unknown command: ${command}`);
141
158
  printHelp();
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export interface AuditEntry {
5
+ timestamp: string;
6
+ branch: string;
7
+ commit: string;
8
+ user: string;
9
+ reason: string;
10
+ bypass_type: string;
11
+ gate_count: number;
12
+ }
13
+
14
+ const AUDIT_LOG_FILE = '.audit-log.jsonl';
15
+ const MAX_ENTRIES = 100;
16
+ const ROLLING_WINDOW_DAYS = 30;
17
+ const MAX_BYPASS_THRESHOLD = 3;
18
+
19
+ export function appendAuditEntry(entry: Omit<AuditEntry, 'gate_count'>, repoRoot: string = process.cwd()): AuditEntry {
20
+ const logPath = join(repoRoot, AUDIT_LOG_FILE);
21
+ const existing = readAllEntries(logPath);
22
+ const gate_count = countRecentBypasses(existing, entry.bypass_type, entry.timestamp);
23
+
24
+ const fullEntry: AuditEntry = { ...entry, gate_count };
25
+ appendFileSync(logPath, JSON.stringify(fullEntry) + '\n', 'utf8');
26
+
27
+ trimLog(logPath);
28
+ return fullEntry;
29
+ }
30
+
31
+ export function readAllEntries(logPath: string = join(process.cwd(), AUDIT_LOG_FILE)): AuditEntry[] {
32
+ if (!existsSync(logPath)) return [];
33
+ const content = readFileSync(logPath, 'utf8').trim();
34
+ if (!content) return [];
35
+ return content.split('\n').map(line => JSON.parse(line) as AuditEntry);
36
+ }
37
+
38
+ export function countBypassesInWindow(entries: AuditEntry[], bypassType: string, windowDays: number = ROLLING_WINDOW_DAYS): number {
39
+ const cutoff = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
40
+ return entries.filter(e => e.bypass_type === bypassType && new Date(e.timestamp) >= cutoff).length;
41
+ }
42
+
43
+ export function exceedsBypassThreshold(entries: AuditEntry[], bypassType: string, threshold: number = MAX_BYPASS_THRESHOLD): { exceeded: boolean; count: number } {
44
+ const count = countBypassesInWindow(entries, bypassType);
45
+ return { exceeded: count > threshold, count };
46
+ }
47
+
48
+ export function formatRetroReport(entries: AuditEntry[], bypassType: string = 'ui-gates'): string {
49
+ const total = entries.filter(e => e.bypass_type === bypassType);
50
+ const count30d = countBypassesInWindow(entries, bypassType);
51
+ const { exceeded } = exceedsBypassThreshold(entries, bypassType);
52
+
53
+ let report = `UI Gate Bypass Report (${bypassType})\n`;
54
+ report += ` Last 30 days: ${count30d} bypasses\n`;
55
+ if (exceeded) {
56
+ report += ` ⚠️ EXCEEDS threshold (${MAX_BYPASS_THRESHOLD}) — retro discussion required\n`;
57
+ }
58
+ if (total.length > 0) {
59
+ report += ` Recent bypasses:\n`;
60
+ total.slice(-5).forEach(e => {
61
+ report += ` - ${e.timestamp} | ${e.branch} | ${e.user}: "${e.reason}"\n`;
62
+ });
63
+ }
64
+ return report;
65
+ }
66
+
67
+ function countRecentBypasses(entries: AuditEntry[], bypassType: string, currentTimestamp: string): number {
68
+ const cutoff = new Date(currentTimestamp).getTime() - ROLLING_WINDOW_DAYS * 24 * 60 * 60 * 1000;
69
+ return entries.filter(e => e.bypass_type === bypassType && new Date(e.timestamp).getTime() >= cutoff).length;
70
+ }
71
+
72
+ function trimLog(logPath: string): void {
73
+ if (!existsSync(logPath)) return;
74
+ const entries = readAllEntries(logPath);
75
+ if (entries.length > MAX_ENTRIES) {
76
+ const trimmed = entries.slice(-MAX_ENTRIES);
77
+ writeFileSync(logPath, trimmed.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8');
78
+ }
79
+ }
@@ -1,4 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { join } from 'path';
2
4
 
3
5
  export interface UiDetectionResult {
4
6
  isUiSprint: boolean;
@@ -19,6 +21,60 @@ const UI_PATH_PATTERNS = [
19
21
  'src/pages/',
20
22
  ];
21
23
 
24
+ // Default built-in exclusions — always ignored regardless of .ui-gate-ignore
25
+ const UI_GATE_DEFAULT_EXCLUSIONS = [
26
+ '**/node_modules/**',
27
+ '**/__tests__/**',
28
+ '**/*.test.*',
29
+ '**/coverage/**',
30
+ '**/dist/**',
31
+ '**/build/**',
32
+ ];
33
+
34
+ /**
35
+ * Check if a file path matches any glob pattern in the exclusions list.
36
+ * Simplified glob matching: ** → wildcard, * → single segment wildcard.
37
+ */
38
+ export function isExcluded(filePath: string, exclusions: string[]): boolean {
39
+ const normalized = filePath.toLowerCase();
40
+ for (const pattern of exclusions) {
41
+ const p = pattern.toLowerCase().replace(/\*\*/g, '_STARSTAR_').replace(/\*/g, '_STAR_');
42
+ if (matchGlob(normalized, p)) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ function matchGlob(filePath: string, pattern: string): boolean {
48
+ // Convert simplified glob to regex
49
+ const regex = pattern
50
+ .replace(/_STARSTAR_/g, '.*')
51
+ .replace(/_STAR_/g, '[^/]*')
52
+ .replace(/\./g, '\\.');
53
+ const re = new RegExp(`^${regex}$`);
54
+ return re.test(filePath);
55
+ }
56
+
57
+ /**
58
+ * Load .ui-gate-ignore file from repo root.
59
+ * Returns array of glob patterns (one per line), excluding comments and empty lines.
60
+ */
61
+ export function loadUiGateIgnore(repoRoot: string = process.cwd()): string[] {
62
+ const ignorePath = join(repoRoot, '.ui-gate-ignore');
63
+ if (!existsSync(ignorePath)) return [];
64
+ const content = readFileSync(ignorePath, 'utf8');
65
+ return content
66
+ .split('\n')
67
+ .map(line => line.trim())
68
+ .filter(line => line.length > 0 && !line.startsWith('#'));
69
+ }
70
+
71
+ /**
72
+ * Full exclusion list: default exclusions + .ui-gate-ignore patterns.
73
+ */
74
+ function getFullExclusions(repoRoot?: string): string[] {
75
+ return [...UI_GATE_DEFAULT_EXCLUSIONS, ...loadUiGateIgnore(repoRoot)];
76
+ }
77
+
22
78
  export function detectUiSprint(baseBranch: string = 'main'): UiDetectionResult {
23
79
  try {
24
80
  const files = getChangedFiles(baseBranch);
@@ -51,11 +107,13 @@ export function parseRenamedFile(file: string): string {
51
107
  return file.includes('→') ? file.split('→')[1].trim() : file;
52
108
  }
53
109
 
54
- export function collectUiMatches(files: string[]): UiDetectionResult {
110
+ export function collectUiMatches(files: string[], repoRoot?: string): UiDetectionResult {
111
+ const exclusions = getFullExclusions(repoRoot);
55
112
  const matchedFiles: string[] = [];
56
113
  const matchedRules = new Set<string>();
57
114
 
58
115
  for (const filePath of files) {
116
+ if (isExcluded(filePath, exclusions)) continue;
59
117
  const rules = getFileMatchRules(filePath);
60
118
  if (rules.length > 0) {
61
119
  matchedFiles.push(filePath);
@@ -97,3 +155,70 @@ export function getFileExtension(filePath: string): string {
97
155
  export function hasUiPathPattern(normalizedPath: string): boolean {
98
156
  return UI_PATH_PATTERNS.some((pattern) => normalizedPath.includes(pattern));
99
157
  }
158
+
159
+ // ─── CLI Entry Point ────────────────────────────────────────────────
160
+
161
+ if (require.main === module) {
162
+ runCli();
163
+ }
164
+
165
+ function runCli(): void {
166
+ const args = process.argv.slice(2);
167
+ const repoRoot = process.cwd();
168
+
169
+ if (args.includes('--push-mode')) {
170
+ runPushMode(repoRoot);
171
+ } else if (args.includes('--check-branch')) {
172
+ runCheckBranch(repoRoot);
173
+ } else {
174
+ runDefault(repoRoot);
175
+ }
176
+ }
177
+
178
+ function runPushMode(repoRoot: string): void {
179
+ let input = '';
180
+ if (process.stdin.isTTY) {
181
+ // If args has --from-stdin but no TTY input, try reading file list from args
182
+ const filesIdx = process.argv.indexOf('--files');
183
+ if (filesIdx !== -1 && process.argv[filesIdx + 1]) {
184
+ input = process.argv[filesIdx + 1];
185
+ processOutput(input, repoRoot);
186
+ return;
187
+ }
188
+ input = '';
189
+ } else {
190
+ const chunks: string[] = [];
191
+ process.stdin.on('data', (chunk) => chunks.push(chunk.toString('utf8')));
192
+ process.stdin.on('end', () => {
193
+ input = chunks.join('').trim();
194
+ processOutput(input, repoRoot);
195
+ });
196
+ return; // async — wait for stdin end
197
+ }
198
+ processOutput(input, repoRoot);
199
+ }
200
+
201
+ function processOutput(input: string, repoRoot: string): void {
202
+ const files = input
203
+ .split('\n')
204
+ .map(parseRenamedFile)
205
+ .filter((f) => f.length > 0);
206
+ const result = collectUiMatches(files, repoRoot);
207
+ // eslint-disable-next-line no-console
208
+ console.log(JSON.stringify(result, null, 2));
209
+ process.exit(result.isUiSprint ? 0 : 1);
210
+ }
211
+
212
+ function runCheckBranch(repoRoot: string): void {
213
+ const result = detectUiSprint('HEAD');
214
+ // eslint-disable-next-line no-console
215
+ console.log(JSON.stringify(result, null, 2));
216
+ process.exit(result.isUiSprint ? 0 : 1);
217
+ }
218
+
219
+ function runDefault(repoRoot: string): void {
220
+ const result = detectUiSprint();
221
+ // eslint-disable-next-line no-console
222
+ console.log(JSON.stringify(result, null, 2));
223
+ process.exit(result.isUiSprint ? 0 : 1);
224
+ }
@@ -0,0 +1,109 @@
1
+ import { execSync } from 'child_process';
2
+ import { writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { collectUiMatches, parseRenamedFile } from './ui-detector';
5
+
6
+ const RESULT_FILE = '.ui-gate-result.json';
7
+
8
+ interface UiReviewResult {
9
+ commit: string;
10
+ verdict: string;
11
+ expires: string;
12
+ design_review: string;
13
+ browser_qa: string;
14
+ ui_changes_detected: string[];
15
+ }
16
+
17
+ function main(): void {
18
+ // eslint-disable-next-line no-console
19
+ console.log('═══ xp-gate ui-review ═══');
20
+ // eslint-disable-next-line no-console
21
+ console.log('');
22
+
23
+ // Get staged + modified files
24
+ let files = '';
25
+ try {
26
+ files = execSync('git diff --cached --name-only && git diff --name-only', { encoding: 'utf8' }).trim();
27
+ } catch {
28
+ // eslint-disable-next-line no-console
29
+ console.log('⚠️ No git repo or no changes. Running in current directory.');
30
+ }
31
+
32
+ const fileList = files
33
+ .split('\n')
34
+ .map(parseRenamedFile)
35
+ .filter(f => f.length > 0);
36
+
37
+ if (fileList.length === 0) {
38
+ // eslint-disable-next-line no-console
39
+ console.log('No files staged or modified. Checking all tracked files.');
40
+ try {
41
+ files = execSync('git ls-files', { encoding: 'utf8' }).trim();
42
+ } catch {
43
+ files = execSync('find . -maxdepth 3 -type f -name "*.ts" -o -name "*.html" -o -name "*.css" -o -name "*.scss" -o -name "*.tsx" -o -name "*.vue" -o -name "*.svelte" 2>/dev/null | grep -v node_modules | grep -v .git', { encoding: 'utf8' }).trim();
44
+ }
45
+ fileList.push(...files.split('\n').filter(f => f.length > 0));
46
+ }
47
+
48
+ const result = collectUiMatches(fileList);
49
+
50
+ if (!result.isUiSprint) {
51
+ // eslint-disable-next-line no-console
52
+ console.log('ℹ️ No UI changes detected in staged/modified files.');
53
+ // eslint-disable-next-line no-console
54
+ console.log(' Nothing to review. Exiting.');
55
+ process.exit(0);
56
+ }
57
+
58
+ // eslint-disable-next-line no-console
59
+ console.log(`🎨 UI changes detected (${result.matchedFiles.length} files):`);
60
+ result.matchedFiles.forEach(f => console.log(` - ${f}`));
61
+ // eslint-disable-next-line no-console
62
+ console.log('');
63
+
64
+ // eslint-disable-next-line no-console
65
+ console.log('Next steps required before push:');
66
+ // eslint-disable-next-line no-console
67
+ console.log(' 1. Run /design-review in your AI agent session');
68
+ // eslint-disable-next-line no-console
69
+ console.log(' 2. Run /qa or /qa-only in your AI agent session');
70
+ // eslint-disable-next-line no-console
71
+ console.log(' 3. Ensure you have .delphi-config.json configured for Delphi review');
72
+ // eslint-disable-next-line no-console
73
+ console.log('');
74
+
75
+ const commit = execSync('git rev-parse HEAD 2>/dev/null || echo "no-commit"', { encoding: 'utf8' }).trim();
76
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
77
+
78
+ const uiResult: UiReviewResult = {
79
+ commit,
80
+ verdict: 'APPROVED',
81
+ expires,
82
+ design_review: 'APPROVED',
83
+ browser_qa: 'APPROVED',
84
+ ui_changes_detected: result.matchedFiles,
85
+ };
86
+
87
+ writeFileSync(join(process.cwd(), RESULT_FILE), JSON.stringify(uiResult, null, 2) + '\n', 'utf8');
88
+
89
+ // eslint-disable-next-line no-console
90
+ console.log(`✅ Generated ${RESULT_FILE} with APPROVED verdict (template)`);
91
+ // eslint-disable-next-line no-console
92
+ console.log(` Commit: ${commit}`);
93
+ // eslint-disable-next-line no-console
94
+ console.log(` Expires: ${expires}`);
95
+ // eslint-disable-next-line no-console
96
+ console.log('');
97
+ // eslint-disable-next-line no-console
98
+ console.log('⚠️ REVIEW THIS FILE before push:');
99
+ // eslint-disable-next-line no-console
100
+ console.log(' - Ensure design_review and browser_qa are actually APPROVED');
101
+ // eslint-disable-next-line no-console
102
+ console.log(' - Edit verdict to REJECTED if issues found');
103
+ // eslint-disable-next-line no-console
104
+ console.log('');
105
+ // eslint-disable-next-line no-console
106
+ console.log('Then: git push (pre-push will validate this file)');
107
+ }
108
+
109
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 6 quality gates, Sprint Flow, and Delphi multi-expert review.",
6
6
  "author": {
@@ -156,15 +156,15 @@ Phase 8: CLEANUP → git worktree remove + sprint-state.json update → status:
156
156
  > **清理提示**: Sprint 完成(Phase 6 SHIP)后,执行 `git worktree remove <worktree_path>` 清理 worktree 目录,同时保留 `.sprint-state/` 中的历史记录。
157
157
 
158
158
  ### Phase 0: THINK(需求探索与设计)
159
- - `brainstorming` (superpowers) **HARD-GATE**: 设计未批准 不可进入实现
159
+ - **Subagent dispatch**: orchestrator 通过 `task(category="deep", load_skills=["brainstorming"])` 启动独立 session
160
+ - 输入: Phase -1 summary(worktree 路径)+ 用户原始需求
160
161
  - 输出: 结构化设计文档 → 直接作为 Phase 1 PLAN 的输入
161
- - 替代原因: office-hours YC 六问适合新产品方向验证,brainstorming 的"设计批准才可进入实现"机制更适合 sprint-flow 场景
162
+ - **HARD-GATE**: 设计未批准 不可进入实现
162
163
 
163
164
  ### Phase 1: PLAN(共识评审)
164
- - `autoplan` (gstack) CEO Design Eng 自动流水线
165
- - `delphi-review` 多轮匿名评审直到共识
166
- - `to-issues` — 将 APPROVED 的 PRD/spec 拆解为垂直切片 Issue(HITL/AFK + 依赖图 + effort 估算)
167
- - **specification.yaml** — 自动生成(含 User Stories 段)
165
+ - **Subagent dispatch**: orchestrator 通过 `task(category="deep", load_skills=["autoplan", "delphi-review", "to-issues"])` 启动独立 session
166
+ - 输入: phase-0-summary.md + 设计文档
167
+ - 输出: `specification.yaml`(含 user_stories[])+ `slices-manifest.json`
168
168
 
169
169
  **条件分支逻辑**:
170
170
  - IF autoplan AUTO_APPROVED + 无 taste_decisions → 跳过 delphi-review
@@ -236,7 +236,10 @@ Phase 2 第一步必须执行 DELPHI-GATE 检查。没有 delphi-review APPROVED
236
236
  - Phase 3 Gate M 会在 push 时验证 mock 密度
237
237
 
238
238
  ### Phase 3: REVIEW + TEST(验证)
239
- - `delphi-review --mode code-walkthrough` 多专家匿名代码走查(代替 cross-model-review)
239
+ - **Subagent dispatch**: orchestrator 通过 `task(category="deep", load_skills=["delphi-review", "test-specification-alignment"])` 启动独立 session
240
+ - 输入: phase-2-summary.md + MVP 代码
241
+ - 输出: 评审报告 + 测试对齐结果
242
+ - `delphi-review --mode code-walkthrough` — 多专家匿名代码走查
240
243
  - `test-specification-alignment` — 测试与 Spec 对齐验证
241
244
  - `browse` (gstack) — 浏览器自动化测试
242
245
  - `k6` / `locust` / `gatling` — 负载/压力测试(可选,后端项目)
@@ -255,21 +258,32 @@ Phase 2 第一步必须执行 DELPHI-GATE 检查。没有 delphi-review APPROVED
255
258
  - 即使用户说"赶时间"、"跳过验收"、"直接发布",也必须暂停等待用户确认
256
259
  - 使用 `@templates/emergent-issues-template.md` 检查清单
257
260
 
258
- ### Phase 5: FEEDBACK CAPTURE(反馈捕获)
259
- - ⚠️ **HARD-GATE: Phase 5 不可跳过。Phase 4 完成后(无论验收通过/跳过/推迟)→ 必须进入 Phase 5 → 完成后才能进入 Phase 6。**
260
- - `learn` (gstack) — 模式记录
261
- - `retro` (gstack) — 工程回顾:提交历史、工作模式、代码质量趋势
262
- - `systematic-debugging` (superpowers) 根因调试(反馈中的 bug 做根因分析,Iron Law:无调查无修复)
261
+ ### Phase 5: FEEDBACK CAPTURE(反馈获取)
262
+ - **Subagent dispatch**: orchestrator 通过 `task(category="quick", load_skills=["learn", "retro", "systematic-debugging"])` 启动独立 session
263
+ - 输入: phase-4-summary.md(验收结果)+ emergent-issues.md(如有)
264
+ - 输出: `feedback-log.md`
265
+ - **HARD-GATE**: Phase 5 不可跳过。Phase 4 完成后 → 必须进入 Phase 5 → 完成后才能进入 Phase 6。
266
+ - **`learn` (gstack)** — Sprint 级复盘(这是 /learn 在本项目中的主要调用时机)
267
+ - ralph-loop 已在 BUILD Phase 内部实现 per-REQ learn(permanent/contextual 分类)
268
+ - Phase 5 额外进行 Sprint 级复盘,总结全 Phase 经验
269
+ - **`retro` (gstack)** — 工程回顾:提交历史、工作模式、代码质量趋势
270
+ - **`systematic-debugging` (superpowers)** — 根因调试
263
271
 
264
272
  ### Phase 6: SHIP(发布准备)
265
- - ⚠️ **HARD-GATE: Phase 5 未完成 不可进入 Phase 6。验证 `.sprint-state/phase-outputs/feedback-log.md` 存在。**
273
+ - **Subagent dispatch**: orchestrator 通过 `task(category="quick", load_skills=["finishing-a-development-branch", "ship"])` 启动独立 session
274
+ - 输入: phase-5-summary.md + feedback-log.md
275
+ - 输出: PR URL
276
+ - **HARD-GATE**: Phase 5 未完成 → 不可进入 Phase 6。验证 `.sprint-state/phase-outputs/feedback-log.md` 存在。
266
277
  - **⚠️ GITHOOKS-GATE**: 再次验证 hooks 完整性(Phase 2 的 TDD 编码已触发提交,SHIP 阶段还会再次提交)
267
278
  - 运行 `githooks/verify.sh` → 缺失 → `githooks/install.sh` → 阻断直至修复
268
279
  - **`finishing-a-development-branch`** (superpowers) — 结构化完成流:4 选项(merge / PR / discard / keep)
269
280
  - `ship` (gstack) — 创建 PR(PR 路径时使用)
270
281
  - Phase 6 输出:PR URL(用于 Phase 7 输入)
271
282
 
272
- ### Phase 7: ⚠️ LAND(合并 + 部署)
283
+ ### Phase 7: LAND(合并 + 部署)
284
+ - **Subagent dispatch**: orchestrator 通过 `task(category="deep", load_skills=["land-and-deploy"])` 启动独立 session
285
+ - 输入: phase-6-summary.md + PR URL
286
+ - 输出: 部署状态 + Canary 报告
273
287
  - 输入:Phase 6 输出的 PR URL
274
288
  - 调用:`land-and-deploy` skill
275
289
  - 流程:
@@ -302,7 +316,124 @@ Phase 2 第一步必须执行 DELPHI-GATE 检查。没有 delphi-review APPROVED
302
316
 
303
317
  ---
304
318
 
305
- ## Output Format (MANDATORY)
319
+ ## 编排层规则(Orchestration Rules)
320
+
321
+ ### Phase Subagent Dispatch Matrix
322
+
323
+ | Phase | 名称 | Subagent? | Category | load_skills | 执行者 |
324
+ |-------|------|:---------:|----------|-------------|--------|
325
+ | -1 | ISOLATE | ❌ | Bash(直接执行) | 无 | orchestrator |
326
+ | 0 | THINK | ✅ | `deep` | `["brainstorming"]` | subagent |
327
+ | 1 | PLAN | ✅ | `deep` | `["autoplan", "delphi-review", "to-issues"]` | subagent |
328
+ | 2 | BUILD | ✅(已有) | ralph-loop | `["test-driven-development"]` | subagent |
329
+ | 3 | REVIEW | ✅ | `deep` | `["delphi-review", "test-specification-alignment"]` | subagent |
330
+ | 4 | USER ACCEPT | ❌ | **强制人工** | 无 | 用户 |
331
+ | 5 | FEEDBACK | ✅ | `quick` | `["learn", "retro", "systematic-debugging"]` | subagent |
332
+ | 6 | SHIP | ✅ | `quick` | `["finishing-a-development-branch", "ship"]` | subagent |
333
+ | 7 | LAND | ✅ | `deep` | `["land-and-deploy"]` | subagent |
334
+ | 8 | CLEANUP | ❌ | Bash(直接执行) | 无 | orchestrator |
335
+
336
+ **上下文隔离原则**:
337
+ - 每个 Subagent 在**独立 session** 中启动,不继承 orchestrator 的对话历史
338
+ - orchestrator session 仅接收 subagent 的最终结果摘要(~13,000 tokens/sprint)
339
+ - 现代模型百万 token 上下文 + 缓存命中 → 单 sprint 不会触发 overflow
340
+
341
+ ### CONTEXT INHERITANCE
342
+
343
+ 每个 Phase subagent 启动时,上下文仅通过以下路径继承:
344
+
345
+ | Phase | 加载来源 | 内容 |
346
+ |-------|---------|------|
347
+ | Phase -1 | 无前置(Bash 操作) | 用户原始需求 + 当前分支状态 |
348
+ | Phase 0 | phase--1-summary(仅路径) | 隔离环境信息(worktree 路径) |
349
+ | Phase 1 | phase-0-summary.md + design-doc | 设计决策 + 结构化规格 |
350
+ | Phase 2 | phase-1-summary.md + specification.yaml | 评审结论 + REQ 列表 |
351
+ | Phase 3 | phase-2-summary.md + MVP 代码 | 构建结果 |
352
+ | Phase 4 | — | **人工验收**。Phase 4 不产生 subagent summary,但用户验收结果记录在 `.sprint-state/phase-outputs/emergent-issues.md`(如有 emergent issues)。Phase 5 加载此文件。 |
353
+ | Phase 5 | phase-4-summary.md + emergent-issues.md | 验收结论 |
354
+ | Phase 6 | phase-5-summary.md + feedback-log.md | 复盘结论 |
355
+ | Phase 7 | phase-6-summary.md + PR URL | 发布准备 |
356
+ | Phase 8 | phase-7-summary(Bash 操作) | 部署结果 |
357
+
358
+ **隔离原则**:每个 Phase subagent 在干净上下文中启动。
359
+ 输入仅限上表对应的摘要文件和一级产出物。
360
+ 不包含前一 Phase 的完整对话、中间文件、失败尝试。
361
+
362
+ **特殊场景**:
363
+ - `--resume-from <phase>`:跳过前置 Phase,直接从指定 Phase 启动。此时要求该 Phase 的前置摘要文件已存在。例如 `--resume-from build` 要求 `phase-1-summary.md` 和 `specification.yaml` 已存在。orchestrator 仍执行 Phase Transition Gate 验证。
364
+ - `--no-isolate`:跳过 Phase -1 ISOLATE,直接在当前分支执行。Phase 0 无 `phase--1-summary` 可用,上下文继承来源为用户原始需求 + 当前 git 状态。所有后续 Phase 的 worktree enforcement 不适用(无 worktree),但仍需保持代码隔离。
365
+ - `next_phase_context` 中的 `{path}` 等变量占位符在实际写入时被替换为具体值。示例中的 `{path}` 应替换为实际 worktree 路径(如 `.worktrees/sprint/sprint-2026-06-01-01`)。
366
+
367
+ ### PHASE TRANSITION RULES
368
+
369
+ 每个 Phase subagent 完成后,必须按顺序执行以下步骤:
370
+
371
+ 1. **写入 Phase 摘要**:创建 `.sprint-state/phase-outputs/phase-{N}-summary.md`
372
+ - 格式:YAML frontmatter + Markdown body(body ≤ 50 行)
373
+ - 大小限制:≤ 40,000 字符(≈ 10,000 tokens)
374
+
375
+ 2. **更新 sprint-state.json**:
376
+ - `phase`: 当前阶段编号
377
+ - `outputs`: 新增当前阶段输出文件路径
378
+
379
+ 3. **等待用户确认 checkpoint**(如适用)
380
+
381
+ ### Phase Summary 格式(YAML Frontmatter Schema)
382
+
383
+ 每个 `phase-N-summary.md` 必须包含以下 YAML frontmatter:
384
+
385
+ ```markdown
386
+ ---
387
+ phase: -1
388
+ phase_name: ISOLATE
389
+ status: completed
390
+ outputs:
391
+ - path: ".worktrees/sprint/sprint-YYYY-MM-DD-NN"
392
+ type: directory
393
+ decisions:
394
+ - title: "Worktree isolation enabled"
395
+ rationale: "Prevent main branch pollution"
396
+ unresolved_issues: []
397
+ next_phase_context: "Worktree created at {path}. All subsequent edits MUST use this workdir."
398
+ ---
399
+
400
+ ## Phase Summary
401
+ {简明摘要,不超过 50 行}
402
+ ```
403
+
404
+ **必填字段**: `phase`, `phase_name`, `status`, `outputs`, `decisions`, `next_phase_context`
405
+ **可选字段**: `unresolved_issues`
406
+
407
+ ### Phase Transition Gate
408
+
409
+ Orchestrator dispatch 下一 Phase 前必须执行验证:
410
+
411
+ ```bash
412
+ SUMMARY=".sprint-state/phase-outputs/phase-${N}-summary.md"
413
+ [ -f "$SUMMARY" ] || { echo "[BLOCK] phase-${N}-summary 不存在"; exit 1; }
414
+ FRONTMARKERS=$(grep -c "^---" "$SUMMARY" 2>/dev/null || echo 0)
415
+ [ "$FRONTMARKERS" -ge 2 ] || { echo "[BLOCK] YAML frontmatter 格式不完整"; exit 1; }
416
+ grep -q "^phase:" "$SUMMARY" || { echo "[BLOCK] 缺少 phase 字段"; exit 1; }
417
+ grep -q "^phase_name:" "$SUMMARY" || { echo "[BLOCK] 缺少 phase_name 字段"; exit 1; }
418
+ grep -q "^status:" "$SUMMARY" || { echo "[BLOCK] 缺少 status 字段"; exit 1; }
419
+ grep -q "^decisions:" "$SUMMARY" || { echo "[BLOCK] 缺少 decisions 字段"; exit 1; }
420
+ grep -q "^outputs:" "$SUMMARY" || { echo "[BLOCK] 缺少 outputs 字段"; exit 1; }
421
+ grep -q "^next_phase_context:" "$SUMMARY" || { echo "[BLOCK] 缺少 next_phase_context"; exit 1; }
422
+ CHARS=$(wc -c < "$SUMMARY" | tr -d ' ')
423
+ [ "$CHARS" -le 40000 ] || { echo "[BLOCK] 摘要超出大小限制 (${CHARS}/40000 chars)"; exit 1; }
424
+ ```
425
+
426
+ **由 orchestrator 强制执行**,不依赖 subagent 自觉遵守。
427
+ 验证失败 → BLOCK,不可 dispatch 下一 Phase。
428
+
429
+ ### WORKTREE ENFORCEMENT(Issue #84)
430
+
431
+ Phase -1 执行完毕后,**所有后续操作(Phase 0 到 Phase 8)的文件编辑、命令执行 MUST 在 worktree 目录下执行**:
432
+
433
+ - **工作目录**:所有 Bash 命令必须通过 `workdir` 参数或 `&&` 链式命令在 worktree 路径下执行
434
+ - **文件写入**:所有 `write`、`edit` 工具的 `filePath` 必须位于 `isolation.worktree_path` 下
435
+ - **验证步骤**:Phase 0 开始前,输出 `[WORKTREE] 后续所有操作将在 {worktree_path} 中进行`
436
+ - **例外**:`.gitignore` 校验(Phase -1 表步 4)和 `git worktree remove`(Phase 8 清理)在仓库根目录执行
306
437
  Sprint state is persisted as JSON in `.sprint-state/sprint-state.json`:
307
438
  ```json
308
439
  {
@@ -51,12 +51,64 @@
51
51
  "name": "stop-at-plan",
52
52
  "category": "boundary",
53
53
  "prompt": "我想先看看方案再决定要不要继续开发。执行/sprint-flow '给XP-Gate添加代码覆盖率趋势分析功能' --stop-at plan",
54
- "expected_output": "只执行到Phase 1(Plan)就停止,输出specification.yaml后等待用户决定。不应自动进入Phase 2。",
54
+ "expected_output": "只执行到Phase 1(Plan)后停止,输出specification.yaml并等待用户决定。不应自动进入Phase 2。",
55
55
  "files": [],
56
56
  "assertions": [
57
57
  {"name": "stops-at-plan", "type": "contains", "value": "specification.yaml"},
58
58
  {"name": "no-build-phase", "type": "not_contains", "value": "Phase 2"}
59
59
  ]
60
+ },
61
+ {
62
+ "id": 5,
63
+ "name": "phase-subagent-dispatch",
64
+ "category": "normal",
65
+ "prompt": "sprint-flow '开发一个用户反馈表单功能',请执行 Phase 0 THINK 阶段",
66
+ "expected_output": "Phase 0 THINK 必须通过独立 subagent 执行(task with category=deep 和 load_skills=['brainstorming']),而不是在同一 session 内。subagent 输入应包含 phase--1-summary 的 worktree 路径。输出设计文档。",
67
+ "files": [],
68
+ "assertions": [
69
+ {"name": "subagent-dispatch-think", "type": "contains", "value": "category"},
70
+ {"name": "brainstorming-skill-loaded", "type": "contains", "value": "brainstorming"},
71
+ {"name": "design-doc-output", "type": "contains", "value": "设计文"}
72
+ ]
73
+ },
74
+ {
75
+ "id": 6,
76
+ "name": "context-inheritance-across-phases",
77
+ "category": "normal",
78
+ "prompt": "sprint-flow 进入 Phase 3 REVIEW 阶段,前两个阶段已完成。Phase 1 产出了 specification.yaml,Phase 2 产出了 MVP 代码。",
79
+ "expected_output": "Phase 3 必须加载 phase-2-summary.md 和 MVP 作为输入。Phase summary 应包含 YAML frontmatter 和必填字段(phase, decisions, next_phase_context)。",
80
+ "files": [],
81
+ "assertions": [
82
+ {"name": "load-phase-2-summary", "type": "contains", "value": "phase-2-summary"},
83
+ {"name": "yaml-frontmatter-schema", "type": "contains", "value": "frontmatter"},
84
+ {"name": "reviews-existing-code", "type": "contains", "value": "MVP"}
85
+ ]
86
+ },
87
+ {
88
+ "id": 7,
89
+ "name": "phase-transition-gate-enforcement",
90
+ "category": "boundary",
91
+ "prompt": "sprint-flow Phase 1 完成后,Phase summary 文件忘记写了,直接要进入 Phase 2。会发生什么?",
92
+ "expected_output": "Phase Transition Gate 检查 phase-1-summary.md 是否存在。如果缺失,必须 BLOCK 不可 dispatch Phase 2。orchestrator 强制执行验证。",
93
+ "files": [],
94
+ "assertions": [
95
+ {"name": "gate-blocks-missing-summary", "type": "contains", "value": "BLOCK"},
96
+ {"name": "summary-required", "type": "contains", "value": "summary"},
97
+ {"name": "orchestrator-enforces", "type": "contains", "value": "orchestrator"}
98
+ ]
99
+ },
100
+ {
101
+ "id": 8,
102
+ "name": "worktree-enforcement-issue-84",
103
+ "category": "boundary",
104
+ "prompt": "sprint-flow Phase -1 创建了 worktree,但 agent 开始在主目录编辑文件而不是在 worktree 目录下。这有什么问题?",
105
+ "expected_output": "指出 Phase -1 完成后,所有后续文件编辑必须在 worktree 目录下执行。主目录编辑不会同步到 worktree,导致 merge conflicts 和文件遗漏。",
106
+ "files": [],
107
+ "assertions": [
108
+ {"name": "worktree-required", "type": "contains", "value": "worktree"},
109
+ {"name": "all-edits-in-worktree", "type": "contains", "value": "所有"},
110
+ {"name": "merge-conflict-risk", "type": "contains", "value": "merge"}
111
+ ]
60
112
  }
61
113
  ],
62
114
  "trigger_evals": {