@zhuan-ai/zhuanspec 2.16.0 → 2.16.3
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 +37 -0
- package/dist/commands/progress.d.ts +12 -0
- package/dist/commands/progress.js +58 -5
- package/dist/commands/validate-reports.d.ts +17 -0
- package/dist/commands/validate-reports.js +76 -0
- package/dist/core/corrections/select-candidates.d.ts +58 -0
- package/dist/core/corrections/select-candidates.js +357 -0
- package/dist/core/hooks/collect-knowledge.js +14 -5
- package/dist/core/hooks/post-archive.js +25 -13
- package/dist/core/hooks/record-progress.d.ts +46 -0
- package/dist/core/hooks/record-progress.js +137 -48
- package/dist/core/hooks/summarize.js +18 -19
- package/dist/core/hooks/user-input-hook.js +4 -1
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +165 -7
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +6 -21
- package/dist/core/templates/slash-command-templates.js +92 -47
- package/dist/core/templates/tasks-template.js +6 -0
- package/dist/core/templates/tdd-tasks-template.js +8 -0
- package/dist/core/validation/report-schema.d.ts +74 -0
- package/dist/core/validation/report-schema.js +254 -0
- package/dist/utils/phase-utils.d.ts +13 -0
- package/dist/utils/phase-utils.js +38 -5
- package/package.json +23 -20
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { promises as fsPromises, existsSync, readFileSync, unlinkSync } from 'fs';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
|
-
import { PHASE_ORDER } from '../../utils/phase-utils.js';
|
|
12
|
+
import { PHASE_ORDER, PHASE_IDLE_THRESHOLD_MS } from '../../utils/phase-utils.js';
|
|
13
13
|
import { resolveZhuanSpecRoot } from '../../utils/resolve-root.js';
|
|
14
14
|
import { isCodeFile, isTrackedFileForPhase, getOrCreatePhaseBucket, recomputeAccuracyRate, persistAccuracyJson, appendAccuracyDebugLog } from '../metrics/code-accuracy.js';
|
|
15
15
|
import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
|
|
@@ -112,9 +112,14 @@ 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,
|
|
119
|
+
wallClockMs: 0,
|
|
120
|
+
idleMs: 0,
|
|
121
|
+
lastActiveAt: '',
|
|
122
|
+
_lastActiveEpoch: 0,
|
|
118
123
|
}],
|
|
119
124
|
stats: { tokenUsageTotal: 0, contextLoad: 0, durationMs: { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 } },
|
|
120
125
|
};
|
|
@@ -162,6 +167,69 @@ export function getBeijingTimeForFilename() {
|
|
|
162
167
|
const seconds = String(beijingTime.getSeconds()).padStart(2, '0');
|
|
163
168
|
return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`;
|
|
164
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* v2.16+ 活跃耗时累加器
|
|
172
|
+
*
|
|
173
|
+
* 每次 PostToolUse 心跳到达时调用,根据 gap = now - lastActiveAt 判断:
|
|
174
|
+
* gap < PHASE_IDLE_THRESHOLD_MS → 活跃,累加到 durationMs
|
|
175
|
+
* gap >= PHASE_IDLE_THRESHOLD_MS → 空闲,累加到 idleMs 并 push idleSegments
|
|
176
|
+
*
|
|
177
|
+
* 同步刷新 wallClockMs、lastActiveAt、lastUpdatedAt。
|
|
178
|
+
* 返回本次增量便于调用方决定是否刷新 stats。
|
|
179
|
+
*/
|
|
180
|
+
export function tickActivePhaseDuration(entry, nowEpoch = Date.now(), nowTimestamp = getBeijingTime()) {
|
|
181
|
+
// 初始化可选字段
|
|
182
|
+
if (entry.idleMs === undefined)
|
|
183
|
+
entry.idleMs = 0;
|
|
184
|
+
if (entry.wallClockMs === undefined)
|
|
185
|
+
entry.wallClockMs = 0;
|
|
186
|
+
const parse = (s) => {
|
|
187
|
+
if (!s)
|
|
188
|
+
return null;
|
|
189
|
+
const ms = new Date(s.replace(' ', 'T')).getTime();
|
|
190
|
+
return Number.isFinite(ms) ? ms : null;
|
|
191
|
+
};
|
|
192
|
+
// 优先使用毫秒级 epoch(精确),fallback 到秒级字符串解析(兼容老数据)
|
|
193
|
+
const lastTickStr = entry.lastActiveAt || entry.lastUpdatedAt || entry.startedAt;
|
|
194
|
+
const lastTickEpoch = entry._lastActiveEpoch ?? parse(lastTickStr);
|
|
195
|
+
const startEpoch = parse(entry.startedAt);
|
|
196
|
+
// 无法解析时间 → 仅刷新心跳时间
|
|
197
|
+
if (lastTickEpoch == null || startEpoch == null) {
|
|
198
|
+
entry.lastActiveAt = nowTimestamp;
|
|
199
|
+
entry.lastUpdatedAt = nowTimestamp;
|
|
200
|
+
entry._lastActiveEpoch = nowEpoch;
|
|
201
|
+
return { activeDelta: 0, idleDelta: 0 };
|
|
202
|
+
}
|
|
203
|
+
const gap = nowEpoch - lastTickEpoch;
|
|
204
|
+
let activeDelta = 0;
|
|
205
|
+
let idleDelta = 0;
|
|
206
|
+
if (gap > 0) {
|
|
207
|
+
if (gap < PHASE_IDLE_THRESHOLD_MS) {
|
|
208
|
+
// 活跃:累加到 durationMs
|
|
209
|
+
activeDelta = gap;
|
|
210
|
+
entry.durationMs = (entry.durationMs || 0) + gap;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// 空闲:累加到 idleMs + 记录段
|
|
214
|
+
idleDelta = gap;
|
|
215
|
+
entry.idleMs = (entry.idleMs || 0) + gap;
|
|
216
|
+
entry.idleSegments = entry.idleSegments || [];
|
|
217
|
+
entry.idleSegments.push({
|
|
218
|
+
from: lastTickStr,
|
|
219
|
+
to: nowTimestamp,
|
|
220
|
+
durationMs: gap,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// 刷新 wallClockMs = now - startedAt
|
|
225
|
+
const wall = nowEpoch - startEpoch;
|
|
226
|
+
entry.wallClockMs = wall > 0 ? wall : (entry.wallClockMs || 0);
|
|
227
|
+
// 刷新心跳(字符串 + epoch 双写,epoch 用于下次 tick 精确计算)
|
|
228
|
+
entry.lastActiveAt = nowTimestamp;
|
|
229
|
+
entry.lastUpdatedAt = nowTimestamp;
|
|
230
|
+
entry._lastActiveEpoch = nowEpoch;
|
|
231
|
+
return { activeDelta, idleDelta };
|
|
232
|
+
}
|
|
165
233
|
export async function recordProgressHook(options) {
|
|
166
234
|
// Read stdin for Claude Code hook input
|
|
167
235
|
let stdinData = {};
|
|
@@ -419,6 +487,20 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
419
487
|
if (progress.stats.durationMs.techDesign === undefined) {
|
|
420
488
|
progress.stats.durationMs.techDesign = 0;
|
|
421
489
|
}
|
|
490
|
+
// v2.16+ 迁移:老 progress.json 的 phaseDurations entry 缺少 wallClockMs/idleMs/lastActiveAt
|
|
491
|
+
for (const entry of progress.phaseDurations) {
|
|
492
|
+
if (entry.wallClockMs === undefined) {
|
|
493
|
+
// 老版本 durationMs 是墙钟差,复制到 wallClockMs,durationMs 保留作为活跃近似
|
|
494
|
+
entry.wallClockMs = entry.durationMs || 0;
|
|
495
|
+
}
|
|
496
|
+
if (entry.idleMs === undefined) {
|
|
497
|
+
entry.idleMs = 0;
|
|
498
|
+
}
|
|
499
|
+
if (!entry.lastActiveAt) {
|
|
500
|
+
// 进行中 entry 用 lastUpdatedAt 兆底,已结束用 endedAt
|
|
501
|
+
entry.lastActiveAt = entry.endedAt || entry.lastUpdatedAt || entry.startedAt;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
422
504
|
}
|
|
423
505
|
else {
|
|
424
506
|
progress = createNewProgress(changeId);
|
|
@@ -869,42 +951,34 @@ async function runRecordProgress(filePath, toolName, success, stdinData) {
|
|
|
869
951
|
const contextLoadFromStdIn = Number(stdinData.tool_result?.contextLoad || 0);
|
|
870
952
|
progress.stats.tokenUsageTotal += tokenUsageFromEnv + tokenUsageFromStdIn;
|
|
871
953
|
progress.stats.contextLoad += contextLoadFromEnv + contextLoadFromStdIn;
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
.
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
if (phase === 'propose')
|
|
901
|
-
progress.stats.durationMs.propose = elapsedMs;
|
|
902
|
-
if (phase === 'apply')
|
|
903
|
-
progress.stats.durationMs.apply = elapsedMs;
|
|
904
|
-
if (phase === 'review')
|
|
905
|
-
progress.stats.durationMs.review = elapsedMs;
|
|
906
|
-
if (phase === 'archive')
|
|
907
|
-
progress.stats.durationMs.archive = elapsedMs;
|
|
954
|
+
// v2.16+:使用 tickActivePhaseDuration 累加活跃耗时
|
|
955
|
+
// 在 phaseDurations 中找到当前进行中的 entry,tick 一次心跳
|
|
956
|
+
const activeEntry = [...progress.phaseDurations]
|
|
957
|
+
.reverse()
|
|
958
|
+
.find(pd => pd.phase === phase && !pd.endedAt);
|
|
959
|
+
if (activeEntry) {
|
|
960
|
+
const nowEpoch = Date.now();
|
|
961
|
+
const nowTs = getBeijingTime();
|
|
962
|
+
tickActivePhaseDuration(activeEntry, nowEpoch, nowTs);
|
|
963
|
+
// 同步刷新 stats.durationMs(活跃耗时)
|
|
964
|
+
if (phase === 'techDesign')
|
|
965
|
+
progress.stats.durationMs.techDesign = activeEntry.durationMs;
|
|
966
|
+
if (phase === 'propose')
|
|
967
|
+
progress.stats.durationMs.propose = activeEntry.durationMs;
|
|
968
|
+
if (phase === 'apply')
|
|
969
|
+
progress.stats.durationMs.apply = activeEntry.durationMs;
|
|
970
|
+
if (phase === 'review')
|
|
971
|
+
progress.stats.durationMs.review = activeEntry.durationMs;
|
|
972
|
+
if (phase === 'archive')
|
|
973
|
+
progress.stats.durationMs.archive = activeEntry.durationMs;
|
|
974
|
+
// 同步 stats 的可选双轨字段
|
|
975
|
+
if (!progress.stats.wallClockMs)
|
|
976
|
+
progress.stats.wallClockMs = { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 };
|
|
977
|
+
if (!progress.stats.idleMs)
|
|
978
|
+
progress.stats.idleMs = { techDesign: 0, propose: 0, apply: 0, review: 0, archive: 0 };
|
|
979
|
+
if (phase !== 'idle') {
|
|
980
|
+
progress.stats.wallClockMs[phase] = activeEntry.wallClockMs || 0;
|
|
981
|
+
progress.stats.idleMs[phase] = activeEntry.idleMs || 0;
|
|
908
982
|
}
|
|
909
983
|
}
|
|
910
984
|
// === Auto-count tasks from tasks.md ===
|
|
@@ -1087,9 +1161,14 @@ function createNewProgress(changeId) {
|
|
|
1087
1161
|
phaseDurations: [{
|
|
1088
1162
|
phase: initialPhase,
|
|
1089
1163
|
startedAt: getBeijingTime(),
|
|
1164
|
+
lastUpdatedAt: getBeijingTime(),
|
|
1090
1165
|
durationMs: 0,
|
|
1091
1166
|
taskCount: 0,
|
|
1092
1167
|
completedTaskCount: 0,
|
|
1168
|
+
wallClockMs: 0,
|
|
1169
|
+
idleMs: 0,
|
|
1170
|
+
lastActiveAt: getBeijingTime(),
|
|
1171
|
+
_lastActiveEpoch: Date.now(),
|
|
1093
1172
|
}],
|
|
1094
1173
|
stats: {
|
|
1095
1174
|
tokenUsageTotal: 0,
|
|
@@ -1156,16 +1235,16 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
1156
1235
|
timestamp,
|
|
1157
1236
|
triggeredBy: 'initializeProgress',
|
|
1158
1237
|
});
|
|
1159
|
-
// Update previous phase duration
|
|
1238
|
+
// Update previous phase duration with tick
|
|
1160
1239
|
const prevPhaseDuration = progress.phaseDurations.find(pd => pd.phase === previousPhase && !pd.endedAt);
|
|
1161
1240
|
if (prevPhaseDuration) {
|
|
1241
|
+
// 封段前 tick,确保最后活跃时间被累加
|
|
1242
|
+
const nowEpoch = Date.now();
|
|
1243
|
+
tickActivePhaseDuration(prevPhaseDuration, nowEpoch, timestamp);
|
|
1162
1244
|
prevPhaseDuration.endedAt = timestamp;
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
prevPhaseDuration.durationMs = ms;
|
|
1167
|
-
if (previousPhase && previousPhase !== 'idle' && ms > 0 && progress.stats?.durationMs) {
|
|
1168
|
-
progress.stats.durationMs[previousPhase] = ms;
|
|
1245
|
+
// 同步 stats
|
|
1246
|
+
if (previousPhase && previousPhase !== 'idle' && progress.stats?.durationMs) {
|
|
1247
|
+
progress.stats.durationMs[previousPhase] = prevPhaseDuration.durationMs;
|
|
1169
1248
|
}
|
|
1170
1249
|
}
|
|
1171
1250
|
else if (previousPhase && previousPhase !== 'idle') {
|
|
@@ -1181,6 +1260,7 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
1181
1260
|
phase: previousPhase,
|
|
1182
1261
|
startedAt: inferredStart,
|
|
1183
1262
|
endedAt: timestamp,
|
|
1263
|
+
lastUpdatedAt: timestamp,
|
|
1184
1264
|
durationMs: ms,
|
|
1185
1265
|
taskCount: 0,
|
|
1186
1266
|
completedTaskCount: 0,
|
|
@@ -1194,9 +1274,14 @@ export async function initializeProgress(changeId, initialPhase = 'propose') {
|
|
|
1194
1274
|
progress.phaseDurations.push({
|
|
1195
1275
|
phase: initialPhase,
|
|
1196
1276
|
startedAt: timestamp,
|
|
1277
|
+
lastUpdatedAt: timestamp,
|
|
1197
1278
|
durationMs: 0,
|
|
1198
1279
|
taskCount: 0,
|
|
1199
1280
|
completedTaskCount: 0,
|
|
1281
|
+
wallClockMs: 0,
|
|
1282
|
+
idleMs: 0,
|
|
1283
|
+
lastActiveAt: timestamp,
|
|
1284
|
+
_lastActiveEpoch: Date.now(),
|
|
1200
1285
|
});
|
|
1201
1286
|
}
|
|
1202
1287
|
progress.phase = initialPhase;
|
|
@@ -1267,21 +1352,25 @@ function seedInitialPhase(progress, initialPhase, timestamp) {
|
|
|
1267
1352
|
progress.phaseDurations = progress.phaseDurations || [];
|
|
1268
1353
|
const activeEntry = progress.phaseDurations.find(pd => pd.phase === initialPhase && !pd.endedAt);
|
|
1269
1354
|
if (!activeEntry) {
|
|
1270
|
-
// Close any still-open legacy entry
|
|
1355
|
+
// Close any still-open legacy entry with tick
|
|
1271
1356
|
for (const entry of progress.phaseDurations) {
|
|
1272
1357
|
if (!entry.endedAt && entry.phase !== initialPhase) {
|
|
1358
|
+
const nowEpoch = Date.now();
|
|
1359
|
+
tickActivePhaseDuration(entry, nowEpoch, timestamp);
|
|
1273
1360
|
entry.endedAt = timestamp;
|
|
1274
|
-
const startTime = new Date((entry.startedAt || timestamp).replace(' ', 'T')).getTime();
|
|
1275
|
-
const endTime = new Date(timestamp.replace(' ', 'T')).getTime();
|
|
1276
|
-
entry.durationMs = Number.isFinite(startTime) && endTime > startTime ? endTime - startTime : 0;
|
|
1277
1361
|
}
|
|
1278
1362
|
}
|
|
1279
1363
|
progress.phaseDurations.push({
|
|
1280
1364
|
phase: initialPhase,
|
|
1281
1365
|
startedAt: timestamp,
|
|
1366
|
+
lastUpdatedAt: timestamp,
|
|
1282
1367
|
durationMs: 0,
|
|
1283
1368
|
taskCount: 0,
|
|
1284
1369
|
completedTaskCount: 0,
|
|
1370
|
+
wallClockMs: 0,
|
|
1371
|
+
idleMs: 0,
|
|
1372
|
+
lastActiveAt: timestamp,
|
|
1373
|
+
_lastActiveEpoch: Date.now(),
|
|
1285
1374
|
});
|
|
1286
1375
|
}
|
|
1287
1376
|
}
|
|
@@ -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}
|
|
269
|
+
`本轮会话发生过 ${n} 次用户纠偏且尚未沉淀,必须按以下顺序处理(严禁跳步、严禁仅根据最近 N 条摘要拍脑袋决策、严禁自行 Write 知识文件):`,
|
|
275
270
|
'',
|
|
276
271
|
'### 必须执行的步骤',
|
|
277
272
|
'',
|
|
278
|
-
`1.
|
|
279
|
-
'
|
|
280
|
-
`
|
|
281
|
-
'
|
|
282
|
-
'
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
' -
|
|
287
|
-
' -
|
|
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. **用户选"
|
|
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
|
-
|
|
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}
|
|
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)
|
|
@@ -188,7 +188,10 @@ export async function userInputHook(content, responseTimeMs) {
|
|
|
188
188
|
// Skip slash/bang commands (e.g. /zhuanspec:proposal, /compact, /clear, !ls).
|
|
189
189
|
// 这类 prompt 是命令/workflow 触发,非“用户对 AI 产出的纠偏/濄清/补充”,
|
|
190
190
|
// 不应计入 user_inputs.json,更不应放进纠偏溯源池影响 accuracyRate。
|
|
191
|
-
|
|
191
|
+
// 注意:Claude Code 在 slash 命令前会注入 NAK(U+0015) 等 C0 控制字符做标记,
|
|
192
|
+
// 普通 trim() 不会剥控制字符,必须显式剥掉前缀的 C0/DEL 再做正则判定,
|
|
193
|
+
// 否则像 "\u0015/zhuanspec:proposal" 这种命令会漏过过滤被记成普通用户输入。
|
|
194
|
+
const trimmedContent = content.trim().replace(/^[\x00-\x1F\x7F]+/, '');
|
|
192
195
|
if (/^[/!][A-Za-z0-9_:/@.\-]+(\s|$)/.test(trimmedContent) || /^\/[\w:\-]+$/.test(trimmedContent)) {
|
|
193
196
|
logDebug(cwd, {
|
|
194
197
|
event: 'skip',
|
package/dist/core/init.d.ts
CHANGED
|
@@ -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', '
|
|
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:
|
|
873
|
-
dotClaude:
|
|
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
|
}
|