@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.
@@ -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
@@ -83,6 +83,7 @@ export declare class InitCommand {
83
83
  private displayWorkflowDiagram;
84
84
  private formatToolNames;
85
85
  private renderBanner;
86
+ private ensureClaudeHud;
86
87
  private startSpinner;
87
88
  }
88
89
  export {};
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,