@zhuan-ai/zhuanspec 2.10.0 → 2.11.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/dist/cli/index.js +1 -0
- package/dist/commands/design.d.ts +12 -2
- package/dist/commands/design.js +64 -12
- package/dist/commands/progress.js +11 -0
- package/dist/commands/review.d.ts +1 -0
- package/dist/commands/review.js +13 -0
- package/dist/core/completions/command-registry.js +4 -0
- package/dist/core/hooks/deviation-check.js +42 -0
- package/dist/core/hooks/init.js +21 -4
- package/dist/core/hooks/post-apply.js +2 -0
- package/dist/core/hooks/pre-review.d.ts +27 -0
- package/dist/core/hooks/pre-review.js +169 -0
- package/dist/core/hooks/record-progress.d.ts +25 -0
- package/dist/core/hooks/record-progress.js +88 -1
- package/dist/core/hooks/review-hooks.js +171 -3
- package/dist/core/hooks/review-orchestrator.js +55 -8
- package/dist/core/hooks/tdd-phase-hook.d.ts +59 -0
- package/dist/core/hooks/tdd-phase-hook.js +313 -0
- package/dist/core/init.d.ts +1 -0
- package/dist/core/init.js +27 -0
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +139 -23
- package/dist/core/templates/skill-templates.js +21 -1
- package/dist/core/templates/slash-command-templates.js +129 -49
- package/dist/core/templates/tasks-template.js +89 -0
- package/dist/core/validation/strict-rules.d.ts +47 -0
- package/dist/core/validation/strict-rules.js +589 -42
- package/dist/utils/git-repo-detector.js +1 -1
- package/dist/utils/phase-utils.js +14 -1
- package/package.json +22 -20
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostToolUse Hook - TDD Phase Tracking
|
|
3
|
+
*
|
|
4
|
+
* This hook tracks TDD workflow compliance for tasks annotated with @test-case:
|
|
5
|
+
* 1. Tracks file creation order (test files should precede implementation files)
|
|
6
|
+
* 2. Detects test execution results (RED → GREEN → REFACTOR phases)
|
|
7
|
+
* 3. Updates metrics/tdd-phases.json with current TDD status
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
11
|
+
import { atomicWriteJson } from './record-progress.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Test-file detection patterns
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Common test-file naming patterns across languages */
|
|
16
|
+
const TEST_FILE_PATTERNS = [
|
|
17
|
+
/Test\.java$/, // Java – JUnit / TestNG
|
|
18
|
+
/Tests?\.cs$/, // C# – xUnit / NUnit
|
|
19
|
+
/\.test\.[jt]sx?$/, // JS/TS – Jest / Vitest
|
|
20
|
+
/\.spec\.[jt]sx?$/, // JS/TS – Jasmine / Mocha / Vitest
|
|
21
|
+
/_test\.go$/, // Go
|
|
22
|
+
/_test\.py$/, // Python – pytest
|
|
23
|
+
/test_.*\.py$/, // Python – unittest
|
|
24
|
+
/Spec\.(?:scala|kt)$/, // Scala / Kotlin – ScalaTest / Kotest
|
|
25
|
+
];
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Test-result detection patterns (for Bash output)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const TEST_FAILURE_PATTERNS = [
|
|
30
|
+
/FAIL(?:ED|URE)?/i,
|
|
31
|
+
/Tests?\s+failed/i,
|
|
32
|
+
/BUILD FAILURE/i,
|
|
33
|
+
/AssertionError/i,
|
|
34
|
+
/AssertError/i,
|
|
35
|
+
/FAILURES!/,
|
|
36
|
+
/failures:\s*[1-9]/i,
|
|
37
|
+
/errors:\s*[1-9]/i,
|
|
38
|
+
/TEST.*FAIL/i,
|
|
39
|
+
];
|
|
40
|
+
const TEST_SUCCESS_PATTERNS = [
|
|
41
|
+
/PASS(?:ED)?/i,
|
|
42
|
+
/Tests?\s+passed/i,
|
|
43
|
+
/BUILD SUCCESS/i,
|
|
44
|
+
/All tests passed/i,
|
|
45
|
+
/Tests:\s+\d+ passed,\s+\d+ total/i,
|
|
46
|
+
/OK \(\d+ tests?\)/i,
|
|
47
|
+
/\d+ passing/i,
|
|
48
|
+
/failures:\s*0.*errors:\s*0/i,
|
|
49
|
+
];
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Return the path to the TDD phases JSON inside a change directory.
|
|
55
|
+
*/
|
|
56
|
+
function tddPhasesPath(changePath) {
|
|
57
|
+
return path.join(changePath, 'metrics', 'tdd-phases.json');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Determine whether a file path looks like a test file.
|
|
61
|
+
*/
|
|
62
|
+
export function isTestFile(filePath) {
|
|
63
|
+
const basename = path.basename(filePath);
|
|
64
|
+
return TEST_FILE_PATTERNS.some((pattern) => pattern.test(basename));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Load the persisted TDD status for a given task.
|
|
68
|
+
* Returns `null` when no status has been recorded yet.
|
|
69
|
+
*/
|
|
70
|
+
export async function loadTddStatus(changePath, taskId) {
|
|
71
|
+
const jsonPath = tddPhasesPath(changePath);
|
|
72
|
+
if (!(await FileSystemUtils.fileExists(jsonPath))) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const content = await FileSystemUtils.readFile(jsonPath);
|
|
77
|
+
const data = JSON.parse(content);
|
|
78
|
+
return data.tasks?.[taskId] ?? null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Corrupted or unreadable – treat as absent
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Persist the TDD status for a task into `metrics/tdd-phases.json`.
|
|
87
|
+
* Creates the file (and parent directories) when they do not exist.
|
|
88
|
+
*/
|
|
89
|
+
export async function saveTddStatus(changePath, taskId, status) {
|
|
90
|
+
const jsonPath = tddPhasesPath(changePath);
|
|
91
|
+
let data = { tasks: {}, lastUpdated: '' };
|
|
92
|
+
if (await FileSystemUtils.fileExists(jsonPath)) {
|
|
93
|
+
try {
|
|
94
|
+
const content = await FileSystemUtils.readFile(jsonPath);
|
|
95
|
+
data = JSON.parse(content);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Start fresh when the file is corrupted
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
data.tasks[taskId] = status;
|
|
102
|
+
data.lastUpdated = new Date().toISOString();
|
|
103
|
+
// Ensure the metrics directory exists
|
|
104
|
+
const metricsDir = path.dirname(jsonPath);
|
|
105
|
+
await FileSystemUtils.createDirectory(metricsDir);
|
|
106
|
+
await atomicWriteJson(jsonPath, data);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Detect whether a Bash tool invocation contains test-execution output
|
|
110
|
+
* and whether the tests passed or failed.
|
|
111
|
+
*
|
|
112
|
+
* Returns `'pass'`, `'fail'`, or `null` (when unable to determine).
|
|
113
|
+
*/
|
|
114
|
+
function detectTestResult(toolOutput) {
|
|
115
|
+
if (!toolOutput)
|
|
116
|
+
return null;
|
|
117
|
+
const hasFail = TEST_FAILURE_PATTERNS.some((p) => p.test(toolOutput));
|
|
118
|
+
const hasPass = TEST_SUCCESS_PATTERNS.some((p) => p.test(toolOutput));
|
|
119
|
+
// Failure takes precedence when both signals are present (e.g. "1 passed, 1 failed")
|
|
120
|
+
if (hasFail)
|
|
121
|
+
return 'fail';
|
|
122
|
+
if (hasPass)
|
|
123
|
+
return 'pass';
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Determine TDD phase transition based on the current status and latest event.
|
|
128
|
+
*
|
|
129
|
+
* Transition rules:
|
|
130
|
+
* PENDING → RED when a test file is created
|
|
131
|
+
* RED → GREEN when tests pass after initial failure is recorded
|
|
132
|
+
* GREEN → REFACTOR when an implementation file is modified / created after tests pass
|
|
133
|
+
* REFACTOR → DONE when tests still pass after refactoring
|
|
134
|
+
*/
|
|
135
|
+
export function detectTddPhaseTransition(currentStatus, toolName, filePath, toolOutput) {
|
|
136
|
+
const status = { ...currentStatus, updatedAt: new Date().toISOString() };
|
|
137
|
+
const fileIsTest = isTestFile(filePath);
|
|
138
|
+
// Track file creation
|
|
139
|
+
if (fileIsTest) {
|
|
140
|
+
status.testFileCreated = true;
|
|
141
|
+
}
|
|
142
|
+
else if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
|
|
143
|
+
status.implFileCreated = true;
|
|
144
|
+
}
|
|
145
|
+
// Detect test execution results (only relevant when toolName is Bash)
|
|
146
|
+
if (toolName === 'Bash') {
|
|
147
|
+
const result = detectTestResult(toolOutput);
|
|
148
|
+
switch (status.tddPhase) {
|
|
149
|
+
case 'PENDING':
|
|
150
|
+
// If tests are already running and they fail → move to RED
|
|
151
|
+
if (result === 'fail') {
|
|
152
|
+
status.testInitiallyFailed = true;
|
|
153
|
+
status.tddPhase = 'RED';
|
|
154
|
+
}
|
|
155
|
+
// If a test file was just created, also transition to RED
|
|
156
|
+
if (status.testFileCreated && status.tddPhase === 'PENDING') {
|
|
157
|
+
status.tddPhase = 'RED';
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
case 'RED':
|
|
161
|
+
if (result === 'fail') {
|
|
162
|
+
status.testInitiallyFailed = true;
|
|
163
|
+
}
|
|
164
|
+
if (result === 'pass' && status.testInitiallyFailed) {
|
|
165
|
+
status.testFinallyPassed = true;
|
|
166
|
+
status.tddPhase = 'GREEN';
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case 'GREEN':
|
|
170
|
+
// After GREEN, any passing test run during refactor moves to REFACTOR
|
|
171
|
+
if (result === 'pass') {
|
|
172
|
+
status.tddPhase = 'REFACTOR';
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case 'REFACTOR':
|
|
176
|
+
// Stays in REFACTOR; transition to DONE is handled externally
|
|
177
|
+
// when the task is marked complete.
|
|
178
|
+
if (result === 'pass') {
|
|
179
|
+
status.tddPhase = 'DONE';
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Also transition PENDING → RED when a test file is created (non-Bash tool)
|
|
187
|
+
if (status.tddPhase === 'PENDING' && status.testFileCreated) {
|
|
188
|
+
status.tddPhase = 'RED';
|
|
189
|
+
}
|
|
190
|
+
return status;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check whether the given task line from tasks.md contains a @test-case annotation.
|
|
194
|
+
* Returns the list of referenced TC identifiers, or an empty array.
|
|
195
|
+
*/
|
|
196
|
+
function parseTestCaseAnnotations(taskLine) {
|
|
197
|
+
const match = taskLine.match(/@test-case[:\s]+([^\n]+)/i);
|
|
198
|
+
if (!match)
|
|
199
|
+
return [];
|
|
200
|
+
return match[1]
|
|
201
|
+
.split(/[,\s]+/)
|
|
202
|
+
.map((tc) => tc.trim())
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Read tasks.md and locate the line that corresponds to the given taskId.
|
|
207
|
+
* Returns `null` when the task is not found.
|
|
208
|
+
*/
|
|
209
|
+
async function findTaskLine(changePath, taskId) {
|
|
210
|
+
const tasksPath = path.join(changePath, 'tasks.md');
|
|
211
|
+
if (!(await FileSystemUtils.fileExists(tasksPath))) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const content = await FileSystemUtils.readFile(tasksPath);
|
|
215
|
+
const lines = content.split('\n');
|
|
216
|
+
// Normalise taskId for comparison (e.g. "2.1" should match "2.1")
|
|
217
|
+
const normalised = taskId.trim();
|
|
218
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
219
|
+
const line = lines[idx];
|
|
220
|
+
// Match checkbox task lines: "- [ ] 2.1 ..." or "- [x] 2.1 ..."
|
|
221
|
+
const taskMatch = line.match(/^-\s+\[[ x]\]\s+(\S+)/);
|
|
222
|
+
if (taskMatch && taskMatch[1] === normalised) {
|
|
223
|
+
let fullBlock = line;
|
|
224
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
225
|
+
if (/^\s{2,}/.test(lines[i]) && !/^-\s+\[/.test(lines[i].trim())) {
|
|
226
|
+
fullBlock += '\n' + lines[i];
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return fullBlock;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Main hook entry point
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
/**
|
|
241
|
+
* TDD Phase Hook – called after tool use to enforce TDD discipline.
|
|
242
|
+
*
|
|
243
|
+
* @param changePath Absolute path to the current change directory
|
|
244
|
+
* @param toolName Name of the tool just used (e.g. 'Write', 'Edit', 'Bash')
|
|
245
|
+
* @param filePath File affected by the tool invocation
|
|
246
|
+
* @param taskId Current task identifier (e.g. "2.1")
|
|
247
|
+
* @param toolOutput Optional output captured from Bash executions
|
|
248
|
+
*/
|
|
249
|
+
export async function tddPhaseHook(changePath, toolName, filePath, taskId, toolOutput) {
|
|
250
|
+
// 1. Determine whether this task is a TDD task (@test-case annotated)
|
|
251
|
+
const taskLine = await findTaskLine(changePath, taskId);
|
|
252
|
+
if (!taskLine) {
|
|
253
|
+
return { action: 'continue' };
|
|
254
|
+
}
|
|
255
|
+
const testCases = parseTestCaseAnnotations(taskLine);
|
|
256
|
+
if (testCases.length === 0) {
|
|
257
|
+
// Not a TDD task – nothing to enforce
|
|
258
|
+
return { action: 'continue' };
|
|
259
|
+
}
|
|
260
|
+
// 2. Load or initialise TDD status
|
|
261
|
+
let status = await loadTddStatus(changePath, taskId);
|
|
262
|
+
if (!status) {
|
|
263
|
+
status = {
|
|
264
|
+
taskId,
|
|
265
|
+
testCases,
|
|
266
|
+
tddPhase: 'PENDING',
|
|
267
|
+
testFileCreated: false,
|
|
268
|
+
testInitiallyFailed: false,
|
|
269
|
+
testFinallyPassed: false,
|
|
270
|
+
implFileCreated: false,
|
|
271
|
+
updatedAt: new Date().toISOString(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// 3. Check file creation order: test file should be created before impl file
|
|
275
|
+
if (!isTestFile(filePath) && !status.testFileCreated) {
|
|
276
|
+
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
|
|
277
|
+
// Implementation file created/modified before any test file exists
|
|
278
|
+
status.implFileCreated = true;
|
|
279
|
+
status.updatedAt = new Date().toISOString();
|
|
280
|
+
await saveTddStatus(changePath, taskId, status);
|
|
281
|
+
return {
|
|
282
|
+
action: 'warn',
|
|
283
|
+
message: `TDD violation: Implementation file "${path.basename(filePath)}" ` +
|
|
284
|
+
`created before test file. In TDD, write tests first (RED phase).`,
|
|
285
|
+
updatedStatus: status,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 4. Detect phase transitions & test results
|
|
290
|
+
const previousPhase = status.tddPhase;
|
|
291
|
+
status = detectTddPhaseTransition(status, toolName, filePath, toolOutput);
|
|
292
|
+
// 5. Enforce GREEN-phase constraint: tests must pass
|
|
293
|
+
if (previousPhase === 'GREEN' && toolName === 'Bash') {
|
|
294
|
+
const result = detectTestResult(toolOutput);
|
|
295
|
+
if (result === 'fail') {
|
|
296
|
+
await saveTddStatus(changePath, taskId, status);
|
|
297
|
+
return {
|
|
298
|
+
action: 'block',
|
|
299
|
+
message: 'TDD violation: Tests must pass in GREEN phase. ' +
|
|
300
|
+
'Fix the implementation until all tests pass before proceeding.',
|
|
301
|
+
updatedStatus: status,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// 6. Persist updated status
|
|
306
|
+
await saveTddStatus(changePath, taskId, status);
|
|
307
|
+
// 7. Return result
|
|
308
|
+
return {
|
|
309
|
+
action: 'continue',
|
|
310
|
+
updatedStatus: status,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//# sourceMappingURL=tdd-phase-hook.js.map
|
package/dist/core/init.d.ts
CHANGED
package/dist/core/init.js
CHANGED
|
@@ -353,6 +353,8 @@ export class InitCommand {
|
|
|
353
353
|
symbol: PALETTE.white('▌'),
|
|
354
354
|
text: PALETTE.white('AI 工具已配置'),
|
|
355
355
|
});
|
|
356
|
+
// Step 4: Ensure claude-hud is installed (for version display in statusLine)
|
|
357
|
+
await this.ensureClaudeHud();
|
|
356
358
|
// Success message
|
|
357
359
|
this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode, toolConfigResult.rootStubStatus, toolConfigResult.claudeHookSummary);
|
|
358
360
|
}
|
|
@@ -1542,6 +1544,31 @@ export class InitCommand {
|
|
|
1542
1544
|
console.log(PALETTE.white('Welcome to ZhuanSpec!'));
|
|
1543
1545
|
console.log();
|
|
1544
1546
|
}
|
|
1547
|
+
async ensureClaudeHud() {
|
|
1548
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || '~', '.claude');
|
|
1549
|
+
const hudConfigPath = path.join(configDir, 'plugins', 'claude-hud', 'config.json');
|
|
1550
|
+
if (existsSync(hudConfigPath)) {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const spinner = this.startSpinner('正在安装 claude-hud(用于版本信息展示)...');
|
|
1554
|
+
try {
|
|
1555
|
+
execSync('claude plugin install claude-hud', {
|
|
1556
|
+
encoding: 'utf-8',
|
|
1557
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1558
|
+
timeout: 30000,
|
|
1559
|
+
});
|
|
1560
|
+
spinner.stopAndPersist({
|
|
1561
|
+
symbol: PALETTE.white('▌'),
|
|
1562
|
+
text: PALETTE.white('claude-hud 安装完成'),
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
spinner.stopAndPersist({
|
|
1567
|
+
symbol: PALETTE.midGray('▌'),
|
|
1568
|
+
text: PALETTE.midGray('claude-hud 安装失败,可手动执行: claude mcp install claude-hud'),
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1545
1572
|
startSpinner(text) {
|
|
1546
1573
|
return ora({
|
|
1547
1574
|
text,
|