@zhuan-ai/zhuanspec 2.16.0 → 2.16.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.
@@ -112,6 +112,7 @@ export async function recoverProgressJsonForWrite(filePath) {
112
112
  phaseDurations: [{
113
113
  phase: recoveredPhase,
114
114
  startedAt: '',
115
+ lastUpdatedAt: '',
115
116
  durationMs: 0,
116
117
  taskCount: 0,
117
118
  completedTaskCount: 0,
@@ -905,6 +906,15 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
905
906
  progress.stats.durationMs.review = elapsedMs;
906
907
  if (phase === 'archive')
907
908
  progress.stats.durationMs.archive = elapsedMs;
909
+ // v2.15.16:同步刷新活跃段的 phaseDurations 记录,进行中也能从单条直接读出
910
+ // 实时 durationMs / lastUpdatedAt。endedAt 仍仅在 phase 切换时填。
911
+ const activeEntry = [...progress.phaseDurations]
912
+ .reverse()
913
+ .find(pd => pd.phase === phase && !pd.endedAt);
914
+ if (activeEntry) {
915
+ activeEntry.durationMs = elapsedMs;
916
+ activeEntry.lastUpdatedAt = getBeijingTime();
917
+ }
908
918
  }
909
919
  }
910
920
  // === Auto-count tasks from tasks.md ===
@@ -1087,6 +1097,7 @@ function createNewProgress(changeId) {
1087
1097
  phaseDurations: [{
1088
1098
  phase: initialPhase,
1089
1099
  startedAt: getBeijingTime(),
1100
+ lastUpdatedAt: getBeijingTime(),
1090
1101
  durationMs: 0,
1091
1102
  taskCount: 0,
1092
1103
  completedTaskCount: 0,
@@ -1160,6 +1171,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1160
1171
  const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
1161
1172
  if (prevPhaseDuration) {
1162
1173
  prevPhaseDuration.endedAt = timestamp;
1174
+ prevPhaseDuration.lastUpdatedAt = timestamp;
1163
1175
  const startTime = new Date(prevPhaseDuration.startedAt.replace(' ', 'T')).getTime();
1164
1176
  const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
1165
1177
  const ms = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
@@ -1181,6 +1193,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1181
1193
  phase: previousPhase,
1182
1194
  startedAt: inferredStart,
1183
1195
  endedAt: timestamp,
1196
+ lastUpdatedAt: timestamp,
1184
1197
  durationMs: ms,
1185
1198
  taskCount: 0,
1186
1199
  completedTaskCount: 0,
@@ -1194,6 +1207,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
1194
1207
  progress.phaseDurations.push({
1195
1208
  phase: initialPhase,
1196
1209
  startedAt: timestamp,
1210
+ lastUpdatedAt: timestamp,
1197
1211
  durationMs: 0,
1198
1212
  taskCount: 0,
1199
1213
  completedTaskCount: 0,
@@ -1271,6 +1285,7 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1271
1285
  for (const entry of progress.phaseDurations) {
1272
1286
  if (!entry.endedAt && entry.phase !== initialPhase) {
1273
1287
  entry.endedAt = timestamp;
1288
+ entry.lastUpdatedAt = timestamp;
1274
1289
  const startTime = new Date((entry.startedAt || timestamp).replace(' ', 'T')).getTime();
1275
1290
  const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
1276
1291
  entry.durationMs = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
@@ -1279,6 +1294,7 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
1279
1294
  progress.phaseDurations.push({
1280
1295
  phase: initialPhase,
1281
1296
  startedAt: timestamp,
1297
+ lastUpdatedAt: timestamp,
1282
1298
  durationMs: 0,
1283
1299
  taskCount: 0,
1284
1300
  completedTaskCount: 0,
@@ -263,40 +263,39 @@ async function runSummarize() {
263
263
  && progressData.askedPitfallSaved !== true) {
264
264
  pitfallAskPending = true;
265
265
  const n = progressData.corrections.length;
266
- const previews = progressData.corrections
267
- .filter(c => c.event === 'detected')
268
- .slice(-3)
269
- .map((c, i) => ` ${i + 1}. ${c.promptPreview || '(no preview)'}`)
270
- .join('\n');
271
266
  pitfallAdditionalContext = [
272
267
  '## ⚠️ ZhuanSpec 知识沉淀询问(收尾必做)',
273
268
  '',
274
- `本轮会话发生过 ${n} 次用户纠偏且尚未沉淀,必须按以下顺序处理(严禁跳步,严禁自行 Write 知识文件):`,
269
+ `本轮会话发生过 ${n} 次用户纠偏且尚未沉淀,必须按以下顺序处理(严禁跳步、严禁仅根据最近 N 条摘要拍脑袋决策、严禁自行 Write 知识文件):`,
275
270
  '',
276
271
  '### 必须执行的步骤',
277
272
  '',
278
- `1. **用 askUserQuestion 询问用户**(二选一):`,
279
- ' - header: "踩坑沉淀"',
280
- ` - question: "本轮 Apply 阶段发生过 ${n} 次用户纠偏,是否沉淀为项目知识?"`,
281
- ' - 选项 1: "是,沉淀"',
282
- ' - 选项 2: "否,跳过"',
273
+ `1. **先生成决策清单(必须)**:运行`,
274
+ ' ```bash',
275
+ ` zhuanspec progress list-corrections ${changeId}`,
276
+ ' ```',
277
+ ' CLI 会过滤 trivial 确认词(如 "是的/好的/ok")+ 去重(完全重复 + 子串包含)+ 启发式分类,输出到 `zhuanspec/changes/<change-id>/review/pending-corrections.md`,并在 stdout 给出 **合计 / 有效 / 过滤** 三个计数。',
283
278
  '',
284
- `2. **用户选"是"**:**必须调用 \`@skill:zhuanspec:knowledge --mode=correction --correction <change-id>\`** (或等价的 \`/zhuanspec:knowledge\` slash command 并在交互中选择模式 B)完成沉淀。`,
285
- ` - change-id 值为 \`${changeId}\``,
286
- ' - **禁止直接 Write/Edit 创建 \`zhuanspec/knowledge/\` 下的任何文件**,禁止自行判定分类或文件名格式;所有写入由 skill 统一执行。',
287
- ' - skill 将自动完成:读取 user_inputs.json(Level 1)或 summary 关键词批量扫描(Level 2)→ 分类决策树 → 去重扫描 → 套用统一 Front Matter → 写入对应分类目录 → 更新 index.md → 标记 resolve-correction。',
279
+ '2. **用 askUserQuestion 询问用户(三选项,禁止默认)**:',
280
+ ' - header: "踩坑沉淀"',
281
+ ' - question: "本轮有 M 条有效纠偏候选(已过滤 trivial / 去重,详见 review/pending-corrections.md),请选择沉淀范围:"',
282
+ ' - 选项 1: "全部沉淀"',
283
+ ' - 选项 2: "选择性沉淀(请回复编号清单,例如 1,3,5)"',
284
+ ' - 选项 3: "跳过"',
288
285
  '',
289
- `3. **用户选"否"**:运行 \`zhuanspec progress resolve-correction ${changeId} --mark-pitfall-saved\` 标记已处理,不写入任何知识文件。`,
286
+ `3. **用户选 "全部沉淀" 或 "选择性沉淀"**:必须调用 \`@skill:zhuanspec:knowledge --mode=correction --correction <change-id>\` (或 \`/zhuanspec:knowledge\` 模式 B)完成沉淀。change-id 值为 \`${changeId}\`。选择性沉淀时把用户回复的编号串以 \`--include <编号串>\` 透传给 skill。`,
287
+ ' - **禁止直接 Write/Edit 创建 `zhuanspec/knowledge/` 下的任何文件**,禁止自行判定分类或文件名格式;所有写入由 skill 统一执行。',
288
+ ' - skill 必须以 `pending-corrections.md` 作为商定范围的单一源,禁止从会话记忆里“发挥”额外条目。',
290
289
  '',
291
- '### 纠偏记录摘要(最近 3 条 promptPreview)',
292
- previews || ' (none)',
290
+ `4. **用户选 "跳过"**:运行 \`zhuanspec progress resolve-correction ${changeId} --mark-pitfall-saved\` 标记已处理,不写入任何知识文件。`,
293
291
  '',
294
292
  '### 红线提示',
295
293
  '- 禁止绕过 skill 直接 Write/Edit 知识文件(等同于绕过质量门禁)。',
294
+ '- 禁止跳过 list-corrections 一步,仅靠 promptPreview 拍脑袋决策。',
296
295
  '- 禁止新建 decisions/ 或三类目录以外的分类。',
297
296
  '- skill 模式 B 完成后会自动写入 askedPitfallSaved=true,无需人工重复调用 CLI。',
298
297
  ].join('\n');
299
- systemMessage += `\n⚠️ 检测到 ${n} 次用户纠偏未沉淀——请按上方说明调 askUserQuestion,用户选是后必须调用 @skill:zhuanspec:knowledge 而非自行 Write。\n`;
298
+ systemMessage += `\n⚠️ 检测到 ${n} 次用户纠偏未沉淀——请先运行 zhuanspec progress list-corrections ${changeId},再用 askUserQuestion 三选项决策,用户选「全部/选择性沉淀」后必须调用 @skill:zhuanspec:knowledge 而非自行 Write。\n`;
300
299
  }
301
300
  // === Task 0 路径 1:Stop 时按核心产出齐备度自动打 phase baseline ===
302
301
  // 幂等:已打标则跳过;先到先得(auto-file-complete 优于 auto-stop)
@@ -53,6 +53,11 @@ export declare class InitCommand {
53
53
  private fetchRemoteArchitectureFiles;
54
54
  private fetchRemoteArchitectureContent;
55
55
  private cloneRemoteArchitectureRepoToTemp;
56
+ /**
57
+ * Append a JSONL line to ~/.zhuanspec-init-debug.log for offline troubleshooting.
58
+ * Failures are swallowed to avoid breaking init.
59
+ */
60
+ private logInitDebug;
56
61
  private syncBusinessSpecTemplate;
57
62
  private syncBusinessClaudeAssets;
58
63
  private resolveBusinessDirectionRoot;
package/dist/core/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'path';
2
2
  import os from 'os';
3
- import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, readdirSync, readFileSync, rmSync, } from 'fs';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, readdirSync, readFileSync, rmSync, appendFileSync, } from 'fs';
4
4
  import { createPrompt, isBackspaceKey, isDownKey, isEnterKey, isSpaceKey, isUpKey, useKeypress, usePagination, useState, } from '@inquirer/core';
5
5
  import chalk from 'chalk';
6
6
  import ora from 'ora';
@@ -806,15 +806,56 @@ export class InitCommand {
806
806
  }
807
807
  cloneRemoteArchitectureRepoToTemp() {
808
808
  const tempDir = mkdtempSync(path.join(os.tmpdir(), 'zhuanspec-arch-'));
809
+ const startedAt = Date.now();
809
810
  try {
810
- execSync(`git clone --depth 1 --single-branch --branch ${ARCH_REPO_BRANCH} ${ARCH_REPO_URL} "${tempDir}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
811
+ execSync(`git clone --depth 1 --single-branch --branch ${ARCH_REPO_BRANCH} ${ARCH_REPO_URL} "${tempDir}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
812
+ this.logInitDebug({
813
+ scope: 'cloneRemoteArchitectureRepoToTemp',
814
+ result: 'ok',
815
+ repo: ARCH_REPO_URL,
816
+ branch: ARCH_REPO_BRANCH,
817
+ tempDir,
818
+ elapsedMs: Date.now() - startedAt,
819
+ });
811
820
  return tempDir;
812
821
  }
813
- catch {
822
+ catch (err) {
823
+ this.logInitDebug({
824
+ scope: 'cloneRemoteArchitectureRepoToTemp',
825
+ result: 'fail',
826
+ repo: ARCH_REPO_URL,
827
+ branch: ARCH_REPO_BRANCH,
828
+ tempDir,
829
+ elapsedMs: Date.now() - startedAt,
830
+ errorMessage: err?.message ?? String(err),
831
+ stderr: err?.stderr ? String(err.stderr).slice(0, 1000) : undefined,
832
+ stdout: err?.stdout ? String(err.stdout).slice(0, 500) : undefined,
833
+ status: err?.status,
834
+ });
814
835
  rmSync(tempDir, { recursive: true, force: true });
815
836
  return null;
816
837
  }
817
838
  }
839
+ /**
840
+ * Append a JSONL line to ~/.zhuanspec-init-debug.log for offline troubleshooting.
841
+ * Failures are swallowed to avoid breaking init.
842
+ */
843
+ logInitDebug(payload) {
844
+ try {
845
+ const line = JSON.stringify({
846
+ ts: new Date().toISOString(),
847
+ pid: process.pid,
848
+ cwd: process.cwd(),
849
+ businessDirection: this.businessDirection ?? null,
850
+ ...payload,
851
+ });
852
+ const logPath = path.join(os.homedir(), '.zhuanspec-init-debug.log');
853
+ appendFileSync(logPath, line + '\n', 'utf-8');
854
+ }
855
+ catch {
856
+ // ignore
857
+ }
858
+ }
818
859
  syncBusinessSpecTemplate(zhuanspecPath) {
819
860
  if (!this.businessDirection) {
820
861
  return { syncedSpecs: false, syncedChanges: false, syncedKnowledge: false };
@@ -851,10 +892,20 @@ export class InitCommand {
851
892
  dotClaude: { source: 'none', files: { added: [], overwritten: [], skipped: [] } },
852
893
  };
853
894
  if (!this.businessDirection) {
895
+ this.logInitDebug({
896
+ scope: 'syncBusinessClaudeAssets',
897
+ result: 'skip-no-business-direction',
898
+ projectPath,
899
+ });
854
900
  return fallbackResult;
855
901
  }
856
902
  const tempDir = this.cloneRemoteArchitectureRepoToTemp();
857
903
  if (!tempDir) {
904
+ this.logInitDebug({
905
+ scope: 'syncBusinessClaudeAssets',
906
+ result: 'skip-clone-failed',
907
+ projectPath,
908
+ });
858
909
  return fallbackResult;
859
910
  }
860
911
  try {
@@ -868,12 +919,50 @@ export class InitCommand {
868
919
  const resolvedClaudeMdSource = this.resolveClaudeAssetSource(businessClaudeMd, commonClaudeMd);
869
920
  // .claude directory: always from common directory (not business-specific)
870
921
  const commonDotClaude = path.join(commonClaudeRoot, '.claude');
922
+ this.logInitDebug({
923
+ scope: 'syncBusinessClaudeAssets',
924
+ result: 'start-copy',
925
+ projectPath,
926
+ tempDir,
927
+ specsRootExists: existsSync(specsRoot),
928
+ businessRoot,
929
+ commonClaudeRoot,
930
+ commonClaudeRootExists: existsSync(commonClaudeRoot),
931
+ commonDotClaude,
932
+ commonDotClaudeExists: existsSync(commonDotClaude),
933
+ commonDotClaudeHasFiles: existsSync(commonDotClaude)
934
+ ? this.directoryHasAtLeastOneFile(commonDotClaude)
935
+ : false,
936
+ commonDotClaudeTopEntries: existsSync(commonDotClaude)
937
+ ? readdirSync(commonDotClaude)
938
+ : [],
939
+ });
940
+ const claudeMdResult = this.copyClaudeFileIfNeeded(resolvedClaudeMdSource, path.join(projectPath, 'CLAUDE.md'));
941
+ const dotClaudeResult = this.copyClaudeDirectoryFromCommon(commonDotClaude, path.join(projectPath, '.claude'));
942
+ this.logInitDebug({
943
+ scope: 'syncBusinessClaudeAssets',
944
+ result: 'done',
945
+ projectPath,
946
+ claudeMd: claudeMdResult,
947
+ dotClaudeSource: dotClaudeResult.source,
948
+ dotClaudeAdded: dotClaudeResult.files.added.length,
949
+ dotClaudeOverwritten: dotClaudeResult.files.overwritten.length,
950
+ dotClaudeSkipped: dotClaudeResult.files.skipped.length,
951
+ dotClaudeAddedSample: dotClaudeResult.files.added.slice(0, 5),
952
+ });
871
953
  return {
872
- claudeMd: this.copyClaudeFileIfNeeded(resolvedClaudeMdSource, path.join(projectPath, 'CLAUDE.md')),
873
- dotClaude: this.copyClaudeDirectoryFromCommon(commonDotClaude, path.join(projectPath, '.claude')),
954
+ claudeMd: claudeMdResult,
955
+ dotClaude: dotClaudeResult,
874
956
  };
875
957
  }
876
- catch {
958
+ catch (err) {
959
+ this.logInitDebug({
960
+ scope: 'syncBusinessClaudeAssets',
961
+ result: 'exception',
962
+ projectPath,
963
+ errorMessage: err?.message ?? String(err),
964
+ stack: err?.stack ? String(err.stack).slice(0, 500) : undefined,
965
+ });
877
966
  return fallbackResult;
878
967
  }
879
968
  finally {
@@ -991,13 +1080,30 @@ export class InitCommand {
991
1080
  files: { added: [], overwritten: [], skipped: [] },
992
1081
  };
993
1082
  if (!sourceAsset) {
1083
+ this.logInitDebug({
1084
+ scope: 'copyClaudeDirectoryIfNeeded',
1085
+ result: 'skip-no-source-asset',
1086
+ targetPath,
1087
+ });
994
1088
  return emptyResult;
995
1089
  }
996
1090
  if (!existsSync(sourceAsset.path)) {
1091
+ this.logInitDebug({
1092
+ scope: 'copyClaudeDirectoryIfNeeded',
1093
+ result: 'skip-source-not-exists',
1094
+ sourcePath: sourceAsset.path,
1095
+ targetPath,
1096
+ });
997
1097
  return { source: sourceAsset.source, files: { added: [], overwritten: [], skipped: [] } };
998
1098
  }
999
1099
  const repoRoot = this.findGitRootForPath(sourceAsset.path);
1000
1100
  if (!repoRoot) {
1101
+ this.logInitDebug({
1102
+ scope: 'copyClaudeDirectoryIfNeeded',
1103
+ branch: 'fallback-fs-no-git-root',
1104
+ sourcePath: sourceAsset.path,
1105
+ targetPath,
1106
+ });
1001
1107
  return this.copyClaudeDirectoryByFileSystem(sourceAsset, targetPath);
1002
1108
  }
1003
1109
  const canonicalRepoRoot = this.safeRealpath(repoRoot);
@@ -1008,13 +1114,38 @@ export class InitCommand {
1008
1114
  .join('/');
1009
1115
  if (relativeSourceDir.startsWith('..') ||
1010
1116
  path.isAbsolute(relativeSourceDir)) {
1117
+ this.logInitDebug({
1118
+ scope: 'copyClaudeDirectoryIfNeeded',
1119
+ branch: 'fallback-fs-bad-relative',
1120
+ canonicalRepoRoot,
1121
+ canonicalSourcePath,
1122
+ relativeSourceDir,
1123
+ targetPath,
1124
+ });
1011
1125
  return this.copyClaudeDirectoryByFileSystem(sourceAsset, targetPath);
1012
1126
  }
1013
1127
  const filesResult = this.copyGitTrackedDirectoryFiles(canonicalRepoRoot, relativeSourceDir, targetPath);
1014
1128
  const totalFiles = filesResult.added.length + filesResult.overwritten.length + filesResult.skipped.length;
1015
1129
  if (totalFiles === 0) {
1130
+ this.logInitDebug({
1131
+ scope: 'copyClaudeDirectoryIfNeeded',
1132
+ branch: 'git-tracked-empty',
1133
+ canonicalRepoRoot,
1134
+ relativeSourceDir,
1135
+ targetPath,
1136
+ });
1016
1137
  return { source: sourceAsset.source, files: { added: [], overwritten: [], skipped: [] } };
1017
1138
  }
1139
+ this.logInitDebug({
1140
+ scope: 'copyClaudeDirectoryIfNeeded',
1141
+ branch: 'git-tracked-ok',
1142
+ canonicalRepoRoot,
1143
+ relativeSourceDir,
1144
+ targetPath,
1145
+ added: filesResult.added.length,
1146
+ overwritten: filesResult.overwritten.length,
1147
+ skipped: filesResult.skipped.length,
1148
+ });
1018
1149
  return {
1019
1150
  source: sourceAsset.source,
1020
1151
  files: filesResult,
@@ -1139,8 +1270,25 @@ export class InitCommand {
1139
1270
  .map((line) => line.trim())
1140
1271
  .filter((line) => line.length > 0);
1141
1272
  if (trackedFiles.length === 0) {
1273
+ this.logInitDebug({
1274
+ scope: 'copyGitTrackedDirectoryFiles',
1275
+ result: 'empty-tracked-files',
1276
+ gitRoot,
1277
+ sourceDirRelativeToGitRoot,
1278
+ targetDir,
1279
+ rawOutputPreview: output.slice(0, 200),
1280
+ });
1142
1281
  return result;
1143
1282
  }
1283
+ this.logInitDebug({
1284
+ scope: 'copyGitTrackedDirectoryFiles',
1285
+ result: 'tracked-files-loaded',
1286
+ gitRoot,
1287
+ sourceDirRelativeToGitRoot,
1288
+ targetDir,
1289
+ trackedCount: trackedFiles.length,
1290
+ sample: trackedFiles.slice(0, 5),
1291
+ });
1144
1292
  // 确保目标目录存在,但不删除已有内容
1145
1293
  mkdirSync(targetDir, { recursive: true });
1146
1294
  trackedFiles.forEach((repoFilePath) => {
@@ -1178,7 +1326,17 @@ export class InitCommand {
1178
1326
  });
1179
1327
  return result;
1180
1328
  }
1181
- catch {
1329
+ catch (err) {
1330
+ this.logInitDebug({
1331
+ scope: 'copyGitTrackedDirectoryFiles',
1332
+ result: 'exception',
1333
+ gitRoot,
1334
+ sourceDirRelativeToGitRoot,
1335
+ targetDir,
1336
+ errorMessage: err?.message ?? String(err),
1337
+ stderr: err?.stderr ? String(err.stderr).slice(0, 500) : undefined,
1338
+ status: err?.status,
1339
+ });
1182
1340
  return result;
1183
1341
  }
1184
1342
  }