@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 +17 -0
- package/lib/audit-log.ts +79 -0
- package/lib/ui-detector.ts +126 -1
- package/lib/ui-review.ts +109 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/skills/sprint-flow/SKILL.md +146 -15
- package/skills/sprint-flow/evals/evals.json +53 -1
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();
|
package/lib/audit-log.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/ui-detector.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/ui-review.ts
ADDED
|
@@ -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": "xp-gate",
|
|
3
|
-
"version": "0.5.
|
|
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
|
-
-
|
|
159
|
+
- **Subagent dispatch**: orchestrator 通过 `task(category="deep", load_skills=["brainstorming"])` 启动独立 session
|
|
160
|
+
- 输入: Phase -1 summary(worktree 路径)+ 用户原始需求
|
|
160
161
|
- 输出: 结构化设计文档 → 直接作为 Phase 1 PLAN 的输入
|
|
161
|
-
-
|
|
162
|
+
- **HARD-GATE**: 设计未批准 → 不可进入实现
|
|
162
163
|
|
|
163
164
|
### Phase 1: PLAN(共识评审)
|
|
164
|
-
-
|
|
165
|
-
-
|
|
166
|
-
- `
|
|
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
|
|
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
|
-
-
|
|
260
|
-
-
|
|
261
|
-
- `
|
|
262
|
-
-
|
|
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
|
-
-
|
|
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:
|
|
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
|
-
##
|
|
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)
|
|
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": {
|