@zhuan-ai/zhuanspec 2.11.4 → 2.11.6

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.
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import chalk from 'chalk';
3
4
  import { JsonConverter } from '../core/converters/json-converter.js';
4
5
  import { Validator } from '../core/validation/validator.js';
5
6
  import { ChangeParser } from '../core/parsers/change-parser.js';
@@ -211,11 +212,15 @@ export class ChangeCommand {
211
212
  console.log(`Change "${changeName}" is valid`);
212
213
  }
213
214
  else {
214
- console.error(`Change "${changeName}" has issues`);
215
+ console.error(`变更 "${changeName}" 存在问题`);
215
216
  report.issues.forEach(issue => {
216
- const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';
217
- const prefix = issue.level === 'ERROR' ? '✗' : '⚠';
218
- console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
217
+ const label = issue.level === 'ERROR' ? '错误' : '警告';
218
+ if (issue.level === 'ERROR') {
219
+ console.error(chalk.red(`✗ [${label}] ${issue.path}: ${issue.message}`));
220
+ }
221
+ else {
222
+ console.error(chalk.yellow(`⚠ [${label}] ${issue.path}: ${issue.message}`));
223
+ }
219
224
  });
220
225
  // Next steps footer to guide fixing issues
221
226
  this.printNextSteps();
@@ -211,12 +211,12 @@ export class ProgressCommand {
211
211
  });
212
212
  continue;
213
213
  }
214
- // Match "Task X.Y" format: #### Task 1.0: description or #### Task 1.1: description
215
- const taskXYMatch = line.match(/^####\s*Task\s+(\d+\.\d+)\s*:\s*(.+)$/i);
214
+ // Match "Task X.Y" or bare "X.Y" format: #### Task 1.0: description or #### 1.1 description
215
+ const taskXYMatch = line.match(/^####\s*(?:Task\s+)?(\d+\.\d+)[\s:]\s*(.+)$/i);
216
216
  if (taskXYMatch) {
217
217
  const taskId = taskXYMatch[1];
218
218
  const rawDescription = taskXYMatch[2].trim();
219
- const isCompleted = rawDescription.endsWith('');
219
+ const isCompleted = rawDescription.endsWith('\u2705');
220
220
  const description = isCompleted
221
221
  ? rawDescription.slice(0, -1).trim()
222
222
  : rawDescription;
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import { existsSync, readdirSync, readFileSync } from 'fs';
2
3
  import { join } from 'path';
3
4
  import { MarkdownParser } from '../core/parsers/markdown-parser.js';
@@ -205,11 +206,18 @@ export function registerSpecCommand(rootProgram) {
205
206
  console.log(`Specification '${specId}' is valid`);
206
207
  }
207
208
  else {
208
- console.error(`Specification '${specId}' has issues`);
209
+ console.error(`规范 '${specId}' 存在问题`);
209
210
  report.issues.forEach(issue => {
210
- const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
211
- const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
212
- console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
211
+ const label = issue.level === 'ERROR' ? '错误' : issue.level === 'WARNING' ? '警告' : '提示';
212
+ if (issue.level === 'ERROR') {
213
+ console.error(chalk.red(`✗ [${label}] ${issue.path}: ${issue.message}`));
214
+ }
215
+ else if (issue.level === 'WARNING') {
216
+ console.error(chalk.yellow(`⚠ [${label}] ${issue.path}: ${issue.message}`));
217
+ }
218
+ else {
219
+ console.error(`ℹ [${label}] ${issue.path}: ${issue.message}`);
220
+ }
213
221
  });
214
222
  }
215
223
  }
@@ -292,18 +292,25 @@ export class ValidateCommand {
292
292
  }
293
293
  }
294
294
  else {
295
- console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);
295
+ console.error(`${type === 'change' ? '变更' : '规范'} '${id}' 存在问题`);
296
296
  for (const issue of report.issues) {
297
- const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
298
- const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
299
- console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
297
+ const label = issue.level === 'ERROR' ? '错误' : issue.level === 'WARNING' ? '警告' : '提示';
298
+ if (issue.level === 'ERROR') {
299
+ console.error(chalk.red(`✗ [${label}] ${issue.path}: ${issue.message}`));
300
+ }
301
+ else if (issue.level === 'WARNING') {
302
+ console.error(chalk.yellow(`⚠ [${label}] ${issue.path}: ${issue.message}`));
303
+ }
304
+ else {
305
+ console.error(`ℹ [${label}] ${issue.path}: ${issue.message}`);
306
+ }
300
307
  }
301
308
  this.printNextSteps(type);
302
309
  }
303
310
  // Print strict mode checks summary if available
304
311
  if (opts?.strict && report.strictChecks && report.strictChecks.length > 0) {
305
312
  console.log('');
306
- console.log('Strict Mode Checks:');
313
+ console.log('严格模式检查:');
307
314
  for (const check of report.strictChecks) {
308
315
  if (check.passed) {
309
316
  console.log(` ${chalk.green('✓')} ${check.ruleName}`);
@@ -311,7 +318,9 @@ export class ValidateCommand {
311
318
  else {
312
319
  const firstError = check.errors[0] || check.warnings[0] || '';
313
320
  const errorSuffix = firstError ? ` — ${firstError}` : '';
314
- console.log(` ${chalk.red('✗')} ${check.ruleName}${errorSuffix}`);
321
+ const hasErrors = check.errors.length > 0;
322
+ const color = hasErrors ? chalk.red : chalk.yellow;
323
+ console.log(` ${color('✗')} ${check.ruleName}${errorSuffix}`);
315
324
  }
316
325
  }
317
326
  }
@@ -100,7 +100,9 @@ export interface ChangeStatus {
100
100
  * @returns The template content
101
101
  * @throws TemplateLoadError if the template cannot be loaded
102
102
  */
103
- export declare function loadTemplate(schemaName: string, templatePath: string): string;
103
+ export declare function loadTemplate(schemaName: string, templatePath: string, options?: {
104
+ changeName?: string;
105
+ }): string;
104
106
  /**
105
107
  * Loads change context combining graph and completion state.
106
108
  *
@@ -5,6 +5,8 @@ import { ArtifactGraph } from './graph.js';
5
5
  import { detectCompleted } from './state.js';
6
6
  import { resolveSchemaForChange } from '../../utils/change-metadata.js';
7
7
  import { loadProjectConfig, getEffectiveRules } from '../project-config.js';
8
+ import { getTasksTemplate } from '../templates/tasks-template.js';
9
+ import { getTddTasksTemplate } from '../templates/tdd-tasks-template.js';
8
10
  /**
9
11
  * Error thrown when loading a template fails.
10
12
  */
@@ -24,7 +26,13 @@ export class TemplateLoadError extends Error {
24
26
  * @returns The template content
25
27
  * @throws TemplateLoadError if the template cannot be loaded
26
28
  */
27
- export function loadTemplate(schemaName, templatePath) {
29
+ export function loadTemplate(schemaName, templatePath, options) {
30
+ // Prefer TypeScript template functions as single source of truth
31
+ const tsTemplate = loadFromTypeScriptTemplate(schemaName, templatePath, options);
32
+ if (tsTemplate !== null) {
33
+ return tsTemplate;
34
+ }
35
+ // Fallback: load from static file
28
36
  const schemaDir = getSchemaDir(schemaName);
29
37
  if (!schemaDir) {
30
38
  throw new TemplateLoadError(`Schema '${schemaName}' not found`, templatePath);
@@ -41,6 +49,23 @@ export function loadTemplate(schemaName, templatePath) {
41
49
  throw new TemplateLoadError(`Failed to read template: ${ioError.message}`, fullPath);
42
50
  }
43
51
  }
52
+ /**
53
+ * Attempt to load template from TypeScript template functions.
54
+ * Returns null if no TypeScript template is registered for this schema+template combination.
55
+ * This ensures TypeScript templates are the single source of truth,
56
+ * avoiding dual-track desync between .ts functions and static .md files.
57
+ */
58
+ function loadFromTypeScriptTemplate(schemaName, templatePath, options) {
59
+ if (templatePath === 'tasks.md') {
60
+ if (schemaName === 'spec-driven') {
61
+ return getTasksTemplate({ changeId: options?.changeName || '[change-id]' });
62
+ }
63
+ if (schemaName === 'tdd') {
64
+ return getTddTasksTemplate({ changeId: options?.changeName || '[change-id]' });
65
+ }
66
+ }
67
+ return null;
68
+ }
44
69
  /**
45
70
  * Loads change context combining graph and completion state.
46
71
  *
@@ -82,7 +107,7 @@ export function generateInstructions(context, artifactId) {
82
107
  if (!artifact) {
83
108
  throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
84
109
  }
85
- const template = loadTemplate(context.schemaName, artifact.template);
110
+ const template = loadTemplate(context.schemaName, artifact.template, { changeName: context.changeName });
86
111
  const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
87
112
  const unlocks = getUnlockedArtifacts(context.graph, artifactId);
88
113
  // Load project config for context/rules injection
@@ -16,7 +16,7 @@ import fs from 'fs';
16
16
  import { FileSystemUtils } from '../../utils/file-system.js';
17
17
  import { GitRepoDetector } from '../../utils/git-repo-detector.js';
18
18
  import { checkPreApplyConditions } from './pre-apply.js';
19
- import { recordHookTrigger } from './record-progress.js';
19
+ import { recordHookTrigger, detectActiveChange } from './record-progress.js';
20
20
  // Pitfall keywords for knowledge collection
21
21
  const PITFALL_KEYWORDS = [
22
22
  '不对', '错了', '踩坑', '坑', '失败', '问题', 'bug',
@@ -120,9 +120,22 @@ async function readStdin() {
120
120
  });
121
121
  }
122
122
  async function runDeviationCheck(filePath, trigger, promptText, _options) {
123
- const phase = process.env.ZHUANSPEC_PHASE || 'idle';
124
- const changeId = process.env.ZHUANSPEC_CHANGE_ID || '';
123
+ // Priority: env vars (set by SessionStart) > filesystem detection (fallback)
124
+ let phase = process.env.ZHUANSPEC_PHASE || '';
125
+ let changeId = process.env.ZHUANSPEC_CHANGE_ID || '';
125
126
  const currentTask = process.env.ZHUANSPEC_CURRENT_TASK || '';
127
+ // Fallback: if env vars not set, detect active change from filesystem
128
+ if (!changeId || phase === 'idle' || !phase) {
129
+ const cwd = process.cwd();
130
+ const activeChange = await detectActiveChange(cwd, filePath || undefined);
131
+ if (activeChange) {
132
+ changeId = activeChange.changeId;
133
+ phase = activeChange.phase;
134
+ }
135
+ else {
136
+ phase = phase || 'idle';
137
+ }
138
+ }
126
139
  // No change bound or idle phase → allow, post-prompt silences itself to avoid noise
127
140
  if (!changeId || phase === 'idle') {
128
141
  if (trigger === 'post-prompt') {
@@ -76,7 +76,20 @@ export async function initHook(options) {
76
76
  await updateClaudeHudCustomLine(output.versionBanner);
77
77
  }
78
78
  if (options.json) {
79
- console.log(JSON.stringify(output));
79
+ // Output JSON with env at top level for Claude Code hook protocol
80
+ const jsonOutput = {
81
+ continue: output.continue,
82
+ systemMessage: output.systemMessage,
83
+ };
84
+ if (output.hookSpecificOutput?.env) {
85
+ jsonOutput.env = output.hookSpecificOutput.env;
86
+ }
87
+ if (output.hookSpecificOutput?.additionalContext) {
88
+ jsonOutput.hookSpecificOutput = {
89
+ additionalContext: output.hookSpecificOutput.additionalContext,
90
+ };
91
+ }
92
+ console.log(JSON.stringify(jsonOutput));
80
93
  }
81
94
  else {
82
95
  if (output.systemMessage) {
@@ -36,9 +36,18 @@ async function checkTasksCompletion(changePath) {
36
36
  }
37
37
  try {
38
38
  const content = await FileSystemUtils.readFile(tasksPath);
39
- const allTasks = content.match(/^- \[[ x]\] .+$/gm) || [];
40
- const uncompletedTasks = content.match(/^- \[ \] .+$/gm) || [];
41
- const completedTasks = content.match(/^- \[x\] .+$/gm) || [];
39
+ // Support checkbox format: - [ ] / - [x]
40
+ const checkboxAll = content.match(/^- \[[ x]\] .+$/gm) || [];
41
+ const checkboxUncompleted = content.match(/^- \[ \] .+$/gm) || [];
42
+ const checkboxCompleted = content.match(/^- \[x\] .+$/gm) || [];
43
+ // Support header format: #### N.M / #### Task N.M: / #### TN:
44
+ const headerAll = content.match(/^####\s*(?:Task\s+)?\d+\.\d+[\s:].+$|^####\s*T\d+\s*:.+$/gm) || [];
45
+ const headerCompleted = headerAll.filter(t => t.includes('\u2705'));
46
+ const headerUncompleted = headerAll.filter(t => !t.includes('\u2705'));
47
+ // Use checkbox format if found, otherwise fall back to header format
48
+ const allTasks = checkboxAll.length > 0 ? checkboxAll : headerAll;
49
+ const uncompletedTasks = checkboxAll.length > 0 ? checkboxUncompleted : headerUncompleted;
50
+ const completedTasks = checkboxAll.length > 0 ? checkboxCompleted : headerCompleted;
42
51
  if (allTasks.length === 0) {
43
52
  return {
44
53
  passed: false,
@@ -86,8 +95,12 @@ async function checkProgressConsistency(changePath) {
86
95
  const progressContent = await FileSystemUtils.readFile(progressPath);
87
96
  const progress = JSON.parse(progressContent);
88
97
  const tasksContent = await FileSystemUtils.readFile(tasksPath);
89
- const totalFromTasks = (tasksContent.match(/^- \[[ x]\] .+$/gm) || []).length;
90
- const completedFromTasks = (tasksContent.match(/^- \[x\] .+$/gm) || []).length;
98
+ const checkboxTotal = (tasksContent.match(/^- \[[ x]\] .+$/gm) || []).length;
99
+ const checkboxCompleted = (tasksContent.match(/^- \[x\] .+$/gm) || []).length;
100
+ const headerTotal = (tasksContent.match(/^####\s*(?:Task\s+)?\d+\.\d+[\s:].+$|^####\s*T\d+\s*:.+$/gm) || []).length;
101
+ const headerCompleted = (tasksContent.match(/^####\s*(?:Task\s+)?\d+\.\d+[\s:].+$|^####\s*T\d+\s*:.+$/gm) || []).filter(t => t.includes('\u2705')).length;
102
+ const totalFromTasks = checkboxTotal > 0 ? checkboxTotal : headerTotal;
103
+ const completedFromTasks = checkboxTotal > 0 ? checkboxCompleted : headerCompleted;
91
104
  const totalFromProgress = progress.totalTasks || 0;
92
105
  if (totalFromProgress !== totalFromTasks) {
93
106
  return {
@@ -213,6 +213,18 @@ export interface ProgressData {
213
213
  lastEvent?: string;
214
214
  }
215
215
  export declare function recordProgressHook(options: RecordProgressOptions): Promise<void>;
216
+ /**
217
+ * Detect active change from progress.json files.
218
+ *
219
+ * Strategy:
220
+ * 1. Primary — if filePath is under zhuanspec/changes/{changeId}/, extract changeId directly.
221
+ * 2. Fallback — scan all changes and return the most recently updated one
222
+ * (by lastUpdatedAt field; stub files without it fall back to filesystem mtime).
223
+ */
224
+ export declare function detectActiveChange(cwd: string, filePath?: string): Promise<{
225
+ changeId: string;
226
+ phase: Phase;
227
+ } | null>;
216
228
  /**
217
229
  * Initialize progress.json at phase start
218
230
  * This function should be called when a change is created, before any files are written.
@@ -219,7 +219,7 @@ function extractMcpTool(toolName) {
219
219
  * 2. Fallback — scan all changes and return the most recently updated one
220
220
  * (by lastUpdatedAt field; stub files without it fall back to filesystem mtime).
221
221
  */
222
- async function detectActiveChange(cwd, filePath) {
222
+ export async function detectActiveChange(cwd, filePath) {
223
223
  const changesDir = path.join(cwd, 'zhuanspec', 'changes');
224
224
  if (!await FileSystemUtils.directoryExists(changesDir)) {
225
225
  return null;
@@ -544,7 +544,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
544
544
  const tasksContent = await FileSystemUtils.readFile(tasksPath);
545
545
  // Match checkbox format: - [ ] or - [x], and also #### Task X.Y: format
546
546
  const checkboxTasks = tasksContent.match(/^- \[[ x]\] .+$/gm) || [];
547
- const taskHeaderTasks = tasksContent.match(/^####\s*Task\s+\d+\.\d+\s*:.+$/gm) || [];
547
+ const taskHeaderTasks = tasksContent.match(/^####\s*(?:Task\s+)?\d+\.\d+[\s:].+$/gm) || [];
548
548
  const legacyHeaderTasks = tasksContent.match(/^####\s*T\d+\s*:.+$/gm) || [];
549
549
  const totalTaskMatches = checkboxTasks.length > 0 ? checkboxTasks : (taskHeaderTasks.length > 0 ? taskHeaderTasks : legacyHeaderTasks);
550
550
  const completedCheckbox = tasksContent.match(/^- \[x\] .+$/gm) || [];
@@ -552,7 +552,7 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
552
552
  const completedTaskMatches = checkboxTasks.length > 0 ? completedCheckbox : completedHeaders;
553
553
  progress.totalTasks = totalTaskMatches.length;
554
554
  // Update completedTasks array with completed task descriptions
555
- const completedDescriptions = completedTaskMatches.map(t => t.replace(/^- \[x\] /, '').replace(/^####\s*(?:Task\s+\d+\.\d+|T\d+)\s*:\s*/, '').replace(/\u2705$/, '').trim());
555
+ const completedDescriptions = completedTaskMatches.map(t => t.replace(/^- \[x\] /, '').replace(/^####\s*(?:Task\s+)?\d+\.\d+[\s:]\s*/, '').replace(/^####\s*T\d+\s*:\s*/, '').replace(/\u2705$/, '').trim());
556
556
  if (completedDescriptions.length > 0) {
557
557
  progress.completedTasks = completedDescriptions;
558
558
  }
package/dist/core/init.js CHANGED
@@ -1254,7 +1254,7 @@ export class InitCommand {
1254
1254
  hooks: [
1255
1255
  {
1256
1256
  type: 'command',
1257
- command: 'zhuanspec-hook init',
1257
+ command: 'zhuanspec-hook init --json',
1258
1258
  statusMessage: 'Initializing ZhuanSpec...',
1259
1259
  },
1260
1260
  ],