kld-sdd 2.4.19 → 2.5.1
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/kld-sdd-guide.html +1109 -0
- package/lib/command-bridge.js +156 -0
- package/lib/deploy-codebuddy-hooks.js +99 -0
- package/lib/hook-gate-core.js +333 -0
- package/lib/init.js +136 -93
- package/lib/settings-merge.js +85 -0
- package/lib/skills-bundle.js +142 -5
- package/lib/tool-profiles.js +270 -0
- package/package.json +3 -2
- package/skywalk-sdd/index.cjs +1808 -129
- package/templates/commands/kunlunzhima/skill-bridge.md +23 -0
- package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +175 -28
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +42 -21
- package/templates/hooks/codebuddy/hooks/sdd-apply-gate.cjs +16 -0
- package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +395 -0
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +123 -0
- package/templates/hooks/codebuddy/hooks/sdd-pre-tool.cjs +16 -0
- package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +48 -0
- package/templates/hooks/codebuddy/hooks/sdd-skill-apply-gate.cjs +16 -0
- package/templates/hooks/codebuddy/hooks/sdd-stop.cjs +70 -0
- package/templates/hooks/codebuddy/settings.json +72 -0
- package/templates/openspec/proposal.md +0 -1
- package/templates/openspec/spec.md +2 -2
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +65 -356
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +403 -0
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +21 -5
- package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +29 -5
- package/templates/skills/kld-sdd/opsx-check/checklist.md +37 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +47 -51
- package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +52 -96
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
- package/templates/skills/kld-sdd/opsx-rules/SKILL.md +131 -0
- package/templates/skills/kld-sdd/opsx-rules/checklist.md +27 -0
- package/templates/skills/kld-sdd/opsx-rules/reference.md +124 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +47 -51
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +43 -46
- package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +13 -1
package/skywalk-sdd/index.cjs
CHANGED
|
@@ -18,6 +18,26 @@ const { execFileSync } = require('child_process');
|
|
|
18
18
|
|
|
19
19
|
const SCHEMA_VERSION = 2;
|
|
20
20
|
|
|
21
|
+
// result → 状态标记映射(execution-log.md 人读标记;checklist/SKILL 文档同步说明这套映射)
|
|
22
|
+
const RESULT_MARK = { success: '✅OK', partial: '🟡WARN', failure: '❌FAIL' };
|
|
23
|
+
|
|
24
|
+
// 返工原因 6 类(事件状态 + report-time git 推导 + agent 兜底);agent --reason 枚举校验白名单
|
|
25
|
+
// T3.6: 新增 pre-archive-recheck(check 在 test 后重跑,归档前强制复检,无 git diff 证据)
|
|
26
|
+
const REWORK_REASON_CATEGORIES = ['incomplete', 'prev-failed', 'code-changed-after-pass', 'recheck-no-change', 'pre-archive-recheck', 'unspecified'];
|
|
27
|
+
// 返工原因英文 code → 中文展示映射(报告面向人读,英文 code 不直观)
|
|
28
|
+
// 2.6.x 术语调整:将"返工"统一改为"重复执行/流程修正",避免误解为代码重做
|
|
29
|
+
const REWORK_REASON_CN = {
|
|
30
|
+
'incomplete': '未闭环',
|
|
31
|
+
'prev-failed': '前次未完成',
|
|
32
|
+
'code-changed-after-pass': '通过后代码变更',
|
|
33
|
+
'recheck-no-change': '复检无变更',
|
|
34
|
+
'pre-archive-recheck': '归档前强制复检',
|
|
35
|
+
'unspecified': '未归类',
|
|
36
|
+
};
|
|
37
|
+
function createReasonCategory() {
|
|
38
|
+
return { incomplete: 0, 'prev-failed': 0, 'code-changed-after-pass': 0, 'recheck-no-change': 0, 'pre-archive-recheck': 0, unspecified: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
// ── 配置 ──────────────────────────────────────────────
|
|
22
42
|
|
|
23
43
|
/** 获取项目的 skywalk-sdd 数据目录 */
|
|
@@ -89,11 +109,25 @@ function parseJsonOption(args, inlineKey, fileKey, projectRoot, fallback = {}) {
|
|
|
89
109
|
return JSON.parse(inlineValue);
|
|
90
110
|
}
|
|
91
111
|
if (fileValue) {
|
|
92
|
-
|
|
112
|
+
const resolvedFileValue = normalizeDetailsFilePath(fileValue, projectRoot);
|
|
113
|
+
return readJsonFile(resolvedFileValue, projectRoot);
|
|
93
114
|
}
|
|
94
115
|
return fallback;
|
|
95
116
|
}
|
|
96
117
|
|
|
118
|
+
/** 规范化 details-file 路径:相对路径自动归一化到 skywalk-sdd/state/,避免污染项目根目录 */
|
|
119
|
+
function normalizeDetailsFilePath(fileValue, projectRoot) {
|
|
120
|
+
if (!fileValue) return fileValue;
|
|
121
|
+
if (path.isAbsolute(fileValue)) return fileValue;
|
|
122
|
+
const normalized = fileValue.replace(/\\/g, '/');
|
|
123
|
+
// 已显式指向 skywalk-sdd/ 下任意目录 → 保持原样(兼容旧路径与新的 state/ 路径)
|
|
124
|
+
if (normalized.startsWith('skywalk-sdd/')) return fileValue;
|
|
125
|
+
// 默认写入 skywalk-sdd/state/,避免根目录污染
|
|
126
|
+
const stateDir = path.join(getDataDir(projectRoot), 'state');
|
|
127
|
+
ensureDir(stateDir);
|
|
128
|
+
return path.join(stateDir, path.basename(normalized));
|
|
129
|
+
}
|
|
130
|
+
|
|
97
131
|
function cleanOptionalFields(event) {
|
|
98
132
|
for (const key of Object.keys(event)) {
|
|
99
133
|
if (event[key] == null || event[key] === '') {
|
|
@@ -299,6 +333,9 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
|
|
|
299
333
|
completed += 1;
|
|
300
334
|
fileCompleted += 1;
|
|
301
335
|
} else if (/\[\s\]/.test(line)) {
|
|
336
|
+
// P2-1: `- [ ] 无` 是占位符(表示"无文档更新/无风险/无遗留"),不计入 incomplete
|
|
337
|
+
const afterCheckbox = line.replace(/^\s*-\s*\[\s\]\s*/, '').trim();
|
|
338
|
+
if (afterCheckbox === '无') return;
|
|
302
339
|
incomplete += 1;
|
|
303
340
|
fileIncomplete += 1;
|
|
304
341
|
incompleteItems.push({
|
|
@@ -328,6 +365,18 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
|
|
|
328
365
|
};
|
|
329
366
|
}
|
|
330
367
|
|
|
368
|
+
// P0-3: 读取 check-result state 文件(check_result 事件 details 缺 consistency_score 时 fallback)
|
|
369
|
+
function readCheckResultState(projectRoot, changeName) {
|
|
370
|
+
if (!projectRoot || !changeName) return null;
|
|
371
|
+
const statePath = path.join(projectRoot, 'skywalk-sdd', 'state', `check-result-${changeName}.json`);
|
|
372
|
+
if (!fs.existsSync(statePath)) return null;
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
331
380
|
function scanTaskCompletion(projectRoot, changeName) {
|
|
332
381
|
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
333
382
|
const taskFiles = discoverTaskFiles(normalizedRoot, changeName);
|
|
@@ -343,7 +392,77 @@ function scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir) {
|
|
|
343
392
|
return scanTaskCompletionFromFiles(normalizedRoot, changeName, taskFiles);
|
|
344
393
|
}
|
|
345
394
|
|
|
346
|
-
|
|
395
|
+
// ── check-task 子命令辅助函数 ─────────────────────────────
|
|
396
|
+
|
|
397
|
+
/** 在 tasks.md 文件中定位并勾选指定 task_id 的 checkbox */
|
|
398
|
+
function checkTaskInFile(filePath, taskId, dryRun = false) {
|
|
399
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
400
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
401
|
+
const lines = content.split(/\r?\n/);
|
|
402
|
+
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
403
|
+
const taskHeaderRe = new RegExp(`^###\\s*\\[?${escapedTaskId}\\]?(?:\\s|$|\\])`);
|
|
404
|
+
let inTaskSection = false;
|
|
405
|
+
let changed = false;
|
|
406
|
+
const newLines = lines.map((line) => {
|
|
407
|
+
if (/^###\s/.test(line)) {
|
|
408
|
+
inTaskSection = taskHeaderRe.test(line);
|
|
409
|
+
}
|
|
410
|
+
if (inTaskSection && (/^\s*-\s*\[\s\]/.test(line) || /^\s*\*\*状态\*\*:\s*\[\s\]/.test(line))) {
|
|
411
|
+
changed = true;
|
|
412
|
+
return line.replace(/\[ \]/, '[x]');
|
|
413
|
+
}
|
|
414
|
+
return line;
|
|
415
|
+
});
|
|
416
|
+
if (changed && !dryRun) {
|
|
417
|
+
fs.writeFileSync(filePath, newLines.join(eol), 'utf8');
|
|
418
|
+
}
|
|
419
|
+
return { changed, linesChanged: changed ? 1 : 0 };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 同步执行 check-task:扫描变更目录下的 tasks.md 并勾选对应 task_id */
|
|
423
|
+
function runCheckTaskSync(projectRoot, changeName, taskId, dryRun = false) {
|
|
424
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
425
|
+
const taskFiles = discoverTaskFiles(normalizedRoot, changeName);
|
|
426
|
+
if (taskFiles.length === 0) {
|
|
427
|
+
throw new Error(`未找到 change ${changeName} 的 tasks.md`);
|
|
428
|
+
}
|
|
429
|
+
let totalChanged = 0;
|
|
430
|
+
const changedFiles = [];
|
|
431
|
+
for (const filePath of taskFiles) {
|
|
432
|
+
const result = checkTaskInFile(filePath, taskId, dryRun);
|
|
433
|
+
if (result.changed) {
|
|
434
|
+
totalChanged += 1;
|
|
435
|
+
changedFiles.push(path.relative(normalizedRoot, filePath).replace(/\\/g, '/'));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { changed: totalChanged > 0, changed_files: changedFiles, task_files_checked: taskFiles.length };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** check-task 子命令 CLI */
|
|
442
|
+
function cmdCheckTask(args) {
|
|
443
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
444
|
+
const changeName = args.change || args['change-name'];
|
|
445
|
+
const taskId = args['task-id'] || args.task_id;
|
|
446
|
+
const dryRun = Boolean(args['dry-run']) || Boolean(args.dry_run);
|
|
447
|
+
|
|
448
|
+
if (!changeName) {
|
|
449
|
+
console.error('错误: 缺少 --change 参数');
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
if (!taskId) {
|
|
453
|
+
console.error('错误: 缺少 --task-id 参数');
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const result = runCheckTaskSync(projectRoot, changeName, taskId, dryRun);
|
|
458
|
+
console.log(JSON.stringify(result, null, 2));
|
|
459
|
+
if (!result.changed && !dryRun) {
|
|
460
|
+
process.exitCode = 1;
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function discoverFullSpecFiles(changeDir, changeName = null) {
|
|
347
466
|
const specsDir = path.join(changeDir, 'specs');
|
|
348
467
|
const specs = [];
|
|
349
468
|
|
|
@@ -359,7 +478,8 @@ function discoverFullSpecFiles(changeDir) {
|
|
|
359
478
|
|
|
360
479
|
const simpleSpecPath = path.join(changeDir, 'spec.md');
|
|
361
480
|
if (fs.existsSync(simpleSpecPath)) {
|
|
362
|
-
|
|
481
|
+
// M3/N8: simple 模式 capability 优先用原始 changeName(稳定标识),避免归档后 archiveDir 带日期前缀污染
|
|
482
|
+
specs.push({ capability: changeName || path.basename(changeDir), source: simpleSpecPath });
|
|
363
483
|
}
|
|
364
484
|
|
|
365
485
|
return specs.sort((a, b) => a.capability.localeCompare(b.capability));
|
|
@@ -382,9 +502,9 @@ function findArchivedChangeDir(projectRoot, changeName) {
|
|
|
382
502
|
return candidates[0] || null;
|
|
383
503
|
}
|
|
384
504
|
|
|
385
|
-
function syncArchivedSpecs(projectRoot, archiveDir) {
|
|
505
|
+
function syncArchivedSpecs(projectRoot, archiveDir, changeName) {
|
|
386
506
|
const copiedSpecs = [];
|
|
387
|
-
const specFiles = discoverFullSpecFiles(archiveDir);
|
|
507
|
+
const specFiles = discoverFullSpecFiles(archiveDir, changeName);
|
|
388
508
|
|
|
389
509
|
for (const spec of specFiles) {
|
|
390
510
|
const targetSpec = path.join(projectRoot, 'openspec', 'specs', spec.capability, 'spec.md');
|
|
@@ -402,15 +522,24 @@ function syncArchivedSpecs(projectRoot, archiveDir) {
|
|
|
402
522
|
|
|
403
523
|
function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}) {
|
|
404
524
|
const manifestPath = path.join(archiveDir, 'archive-manifest.json');
|
|
405
|
-
const copiedSpecs = syncArchivedSpecs(projectRoot, archiveDir);
|
|
525
|
+
const copiedSpecs = syncArchivedSpecs(projectRoot, archiveDir, changeName);
|
|
526
|
+
// U4: 归档时拷 events jsonl 进 archive/evidence/events/,报告可从归档目录重建(源数据与产物不再分离)
|
|
527
|
+
const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(changeName));
|
|
528
|
+
let evidenceEventsPath = null;
|
|
529
|
+
if (fs.existsSync(eventsSrcDir)) {
|
|
530
|
+
const eventsDstDir = path.join(archiveDir, 'evidence', 'events');
|
|
531
|
+
copyDirSync(eventsSrcDir, eventsDstDir);
|
|
532
|
+
evidenceEventsPath = toProjectRelative(projectRoot, eventsDstDir);
|
|
533
|
+
}
|
|
406
534
|
const manifest = {
|
|
407
535
|
change: changeName,
|
|
408
536
|
archived_at: options.archivedAt || nowISO(),
|
|
409
|
-
reason: options.reason || '',
|
|
537
|
+
reason: options.reason || '变更已完成实施',
|
|
410
538
|
method: options.method || 'skywalk-full-spec-archive',
|
|
411
539
|
source_path: `openspec/changes/${changeName}`,
|
|
412
540
|
archive_path: toProjectRelative(projectRoot, archiveDir),
|
|
413
541
|
copied_specs: copiedSpecs,
|
|
542
|
+
evidence_events_path: evidenceEventsPath,
|
|
414
543
|
};
|
|
415
544
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
416
545
|
return {
|
|
@@ -423,10 +552,7 @@ function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}
|
|
|
423
552
|
function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, options = {}) {
|
|
424
553
|
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
425
554
|
const activeChangeDir = getChangeDir(normalizedRoot, changeName);
|
|
426
|
-
|
|
427
|
-
const reportPath = options.reportOutput
|
|
428
|
-
? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
|
|
429
|
-
: '';
|
|
555
|
+
let archiveReason = details.archive_result?.reason || options.reason || '变更已完成实施';
|
|
430
556
|
|
|
431
557
|
let archiveDir = details.archive_result?.archive_path
|
|
432
558
|
? path.resolve(normalizedRoot, details.archive_result.archive_path)
|
|
@@ -444,21 +570,34 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
|
|
|
444
570
|
changed: false,
|
|
445
571
|
archive_result: details.archive_result || {},
|
|
446
572
|
task_completion: details.archive_result?.task_completion || null,
|
|
573
|
+
reportPath: '',
|
|
447
574
|
};
|
|
448
575
|
}
|
|
449
576
|
|
|
577
|
+
// 显式 reportOutput 优先;否则默认落归档后 archive 目录的 reports/ 子目录
|
|
578
|
+
const reportPath = options.reportOutput
|
|
579
|
+
? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
|
|
580
|
+
: path.join(archiveDir, 'reports', `${safeChangeName(changeName)}-report.md`);
|
|
581
|
+
|
|
582
|
+
// P2-2: task_completion 提前计算,has_incomplete 时调整归档原因
|
|
583
|
+
const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
|
|
584
|
+
if (taskCompletion?.has_incomplete && !details.archive_result?.reason) {
|
|
585
|
+
archiveReason = `部分完成(${taskCompletion.incomplete} 项验收未勾选)`;
|
|
586
|
+
}
|
|
450
587
|
const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
|
|
451
588
|
reason: archiveReason,
|
|
452
589
|
method: archiveMethod || 'skywalk-full-spec-archive',
|
|
453
590
|
archivedAt: details.archive_result?.archived_at || nowISO(),
|
|
454
591
|
});
|
|
455
|
-
const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
|
|
456
592
|
|
|
457
593
|
const archiveResult = {
|
|
458
594
|
reason: archiveReason,
|
|
459
595
|
method: manifestInfo.manifest.method,
|
|
460
596
|
archive_path: manifestInfo.manifest.archive_path,
|
|
461
|
-
report_path:
|
|
597
|
+
report_path: toProjectRelative(normalizedRoot, reportPath),
|
|
598
|
+
// report_html_path 为预期路径:由 cmdEnd 落盘阶段写入(try/catch 容错,失败时不阻塞 md 主产物)。
|
|
599
|
+
// 此处无条件派生,html 实际落盘失败时该路径可能不存在;语义为"预期路径",消费者不应假定文件已存在。
|
|
600
|
+
report_html_path: toProjectRelative(normalizedRoot, reportPath.replace(/\.md$/i, '.html')),
|
|
462
601
|
manifest_path: toProjectRelative(normalizedRoot, manifestInfo.manifest_path),
|
|
463
602
|
task_completion: taskCompletion,
|
|
464
603
|
copied_specs: manifestInfo.copied_specs,
|
|
@@ -468,6 +607,7 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
|
|
|
468
607
|
changed: true,
|
|
469
608
|
archive_result: archiveResult,
|
|
470
609
|
task_completion: taskCompletion,
|
|
610
|
+
reportPath,
|
|
471
611
|
};
|
|
472
612
|
}
|
|
473
613
|
|
|
@@ -494,8 +634,14 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
|
|
|
494
634
|
const archiveDir = nextAvailableDir(archiveRoot, `${archiveDate}-${changeName}`);
|
|
495
635
|
|
|
496
636
|
copyDirSync(sourceDir, archiveDir);
|
|
637
|
+
// P2-2: 根据 task_completion 调整归档原因,has_incomplete 时不写"已完成实施"
|
|
638
|
+
const tcForReason = scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
|
|
639
|
+
let archiveReason = options.reason || '变更已完成实施';
|
|
640
|
+
if (tcForReason?.has_incomplete) {
|
|
641
|
+
archiveReason = `部分完成(${tcForReason.incomplete} 项验收未勾选)`;
|
|
642
|
+
}
|
|
497
643
|
const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
|
|
498
|
-
reason:
|
|
644
|
+
reason: archiveReason,
|
|
499
645
|
method: 'skywalk-full-spec-archive',
|
|
500
646
|
});
|
|
501
647
|
const manifest = manifestInfo.manifest;
|
|
@@ -522,6 +668,89 @@ function appendEvent(dataDir, changeName, event) {
|
|
|
522
668
|
if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
523
669
|
throw new Error(`事件文件写入验证失败: ${file}`);
|
|
524
670
|
}
|
|
671
|
+
appendMarkdownLog(dataDir, changeName, event);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
|
|
675
|
+
* execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
|
|
676
|
+
function formatLocalTimestamp(iso) {
|
|
677
|
+
const d = new Date(iso || '');
|
|
678
|
+
if (Number.isNaN(d.getTime())) return String(iso || '');
|
|
679
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
680
|
+
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
|
681
|
+
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
682
|
+
const offsetMin = -d.getTimezoneOffset();
|
|
683
|
+
const sign = offsetMin >= 0 ? '+' : '-';
|
|
684
|
+
const abs = Math.abs(offsetMin);
|
|
685
|
+
return `${local} (UTC${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)})`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** 把事件同步追加为人读 markdown 行到 openspec/changes/<change>/logs/execution-log.md
|
|
689
|
+
* (telemetry 副本;change 已归档时写入 archive 目录;失败不阻塞 telemetry 主流程) */
|
|
690
|
+
function appendMarkdownLog(dataDir, changeName, event) {
|
|
691
|
+
try {
|
|
692
|
+
const projectRoot = path.dirname(dataDir);
|
|
693
|
+
const activeChangeDir = getChangeDir(projectRoot, changeName);
|
|
694
|
+
// Q1: 未 openspec new change 前(无 .openspec.yaml 且无 archive-manifest.json)不创建 logs/,避免 propose 残留误报
|
|
695
|
+
const hasChangeMarker = fs.existsSync(path.join(activeChangeDir, '.openspec.yaml'))
|
|
696
|
+
|| fs.existsSync(path.join(activeChangeDir, 'archive-manifest.json'));
|
|
697
|
+
if (!hasChangeMarker && !fs.existsSync(activeChangeDir)) {
|
|
698
|
+
// 目录本身不存在,检查是否为已归档变更
|
|
699
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
700
|
+
if (!archiveDir) return; // 未 init 且未归档 → 跳过 logs/ 创建
|
|
701
|
+
const archiveLogsDir = path.join(archiveDir, 'logs');
|
|
702
|
+
ensureDir(archiveLogsDir);
|
|
703
|
+
const file = path.join(archiveLogsDir, 'execution-log.md');
|
|
704
|
+
if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
705
|
+
const created = formatLocalTimestamp(event.timestamp || nowISO());
|
|
706
|
+
fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
|
|
707
|
+
}
|
|
708
|
+
const ts = formatLocalTimestamp(event.timestamp || nowISO());
|
|
709
|
+
const stage = event.command || event.stage || 'unknown';
|
|
710
|
+
const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
|
|
711
|
+
if (event.type === 'process_note') {
|
|
712
|
+
const kind = event.details?.kind || 'note';
|
|
713
|
+
const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
|
|
714
|
+
const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
|
|
715
|
+
fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const type = event.type || 'event';
|
|
719
|
+
const result = event.result || '';
|
|
720
|
+
const mark = RESULT_MARK[result] || '📝';
|
|
721
|
+
fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
let logsDir = path.join(activeChangeDir, 'logs');
|
|
725
|
+
if (!fs.existsSync(activeChangeDir)) {
|
|
726
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
727
|
+
if (archiveDir) logsDir = path.join(archiveDir, 'logs');
|
|
728
|
+
}
|
|
729
|
+
ensureDir(logsDir);
|
|
730
|
+
const file = path.join(logsDir, 'execution-log.md');
|
|
731
|
+
if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
732
|
+
const created = formatLocalTimestamp(event.timestamp || nowISO());
|
|
733
|
+
fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
|
|
734
|
+
}
|
|
735
|
+
const ts = formatLocalTimestamp(event.timestamp || nowISO());
|
|
736
|
+
const stage = event.command || event.stage || 'unknown';
|
|
737
|
+
const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
|
|
738
|
+
// process_note 渲染为带 kind/round 的叙事行(U2 叙事信号源 + U3 修复可追溯)
|
|
739
|
+
if (event.type === 'process_note') {
|
|
740
|
+
const kind = event.details?.kind || 'note';
|
|
741
|
+
const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
|
|
742
|
+
const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
|
|
743
|
+
fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const type = event.type || 'event';
|
|
747
|
+
const result = event.result || '';
|
|
748
|
+
const mark = RESULT_MARK[result] || '📝';
|
|
749
|
+
fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
|
|
750
|
+
} catch (err) {
|
|
751
|
+
// markdown 副本失败不影响 telemetry 主流程,但输出 stderr 提示保留可观测性(避免持续性失败长期静默)
|
|
752
|
+
try { console.error(`[telemetry] execution-log 副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
|
|
753
|
+
}
|
|
525
754
|
}
|
|
526
755
|
|
|
527
756
|
/** 读取指定 change 的所有事件 */
|
|
@@ -659,8 +888,40 @@ function computeChangeMetrics(changeName, events) {
|
|
|
659
888
|
const agentTypes = [...new Set(changeEvents.filter(e => e.agent_type).map(e => e.agent_type))];
|
|
660
889
|
const isCompleted = !!archiveEnd;
|
|
661
890
|
|
|
891
|
+
// ── 变更摘要(execution-log 与报告共用,消除 1 vs 2 不一致;T1.3 + T4.4) ──
|
|
892
|
+
const stageStartCount = starts.length;
|
|
893
|
+
const stageEndCount = ends.length;
|
|
894
|
+
const distinctStartStages = new Set(starts.map((e) => e.command)).size;
|
|
895
|
+
const stageRepeatCount = Math.max(0, stageStartCount - distinctStartStages);
|
|
896
|
+
// A2(v3): 过滤空壳 test_result(passed=0 && failed=0 && duration_ms<=0/null),
|
|
897
|
+
// 只在真实测试结果中取最新一条。todo-cli 场景:claude-hook 空壳占位 + 真实 19/0 混合时,
|
|
898
|
+
// 旧逻辑取最晚空壳 → test_pass={0,0} 或 stage_end(无数值) → null,掩盖真实通过率。
|
|
899
|
+
// 不复用 isRealTestDetails(其要求 command 非空,会误过滤无 command 的真实 test_result)
|
|
900
|
+
const realTestEvents = testEvents.filter(e => {
|
|
901
|
+
const r = getTestResults(e);
|
|
902
|
+
if (!r) return false;
|
|
903
|
+
return r.passed > 0 || r.failed > 0 || (Number.isFinite(r.duration_ms) && r.duration_ms > 0);
|
|
904
|
+
});
|
|
905
|
+
const latestTestEvent = realTestEvents.length > 0 ? latestByTimestamp(realTestEvents) : null;
|
|
906
|
+
const latestTestResults = latestTestEvent ? getTestResults(latestTestEvent) : null;
|
|
907
|
+
const testPassSummary = latestTestResults
|
|
908
|
+
? { passed: Number(latestTestResults.passed) || 0, failed: Number(latestTestResults.failed) || 0 }
|
|
909
|
+
: null;
|
|
910
|
+
const effectiveStageDurationMs = ends.reduce((s, e) => s + (Number(e.duration_ms) || 0), 0);
|
|
911
|
+
const idleTimeMs = changeLeadTime != null
|
|
912
|
+
? Math.max(0, changeLeadTime - effectiveStageDurationMs)
|
|
913
|
+
: null;
|
|
914
|
+
|
|
662
915
|
return {
|
|
663
916
|
change: changeName,
|
|
917
|
+
change_summary: {
|
|
918
|
+
stage_start_count: stageStartCount,
|
|
919
|
+
stage_end_count: stageEndCount,
|
|
920
|
+
stage_repeat_count: stageRepeatCount,
|
|
921
|
+
test_pass: testPassSummary,
|
|
922
|
+
effective_stage_duration_ms: effectiveStageDurationMs,
|
|
923
|
+
idle_time_ms: idleTimeMs,
|
|
924
|
+
},
|
|
664
925
|
process_health: {
|
|
665
926
|
critical_path_coverage: criticalPathCoverage,
|
|
666
927
|
covered_core_stages: coveredCoreStages,
|
|
@@ -682,9 +943,12 @@ function computeChangeMetrics(changeName, events) {
|
|
|
682
943
|
apply_test_fix_cycles: applyCount,
|
|
683
944
|
coverage_trend: coverageTrend,
|
|
684
945
|
q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
|
|
946
|
+
q1_human_status: conformanceMetrics.human_status,
|
|
685
947
|
q4_spec_driven_test_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
|
|
686
948
|
conformance_counts: conformanceMetrics.conformance_counts,
|
|
687
|
-
|
|
949
|
+
conformance_agent_confirmed: conformanceMetrics.agent_confirmed,
|
|
950
|
+
conformance_reviewer: conformanceMetrics.reviewer,
|
|
951
|
+
conformance_reviewer_independence: conformanceMetrics.reviewer_independence,
|
|
688
952
|
spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
|
|
689
953
|
p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
|
|
690
954
|
ai_adoption_level: aiAdoptionMetrics.adoption_level,
|
|
@@ -856,6 +1120,7 @@ function createReworkBucket(command, capability) {
|
|
|
856
1120
|
superseded_open_stages: 0,
|
|
857
1121
|
unresolved_open_stages: 0,
|
|
858
1122
|
rework_duration_ms: 0,
|
|
1123
|
+
reasons_by_category: createReasonCategory(),
|
|
859
1124
|
};
|
|
860
1125
|
}
|
|
861
1126
|
|
|
@@ -873,6 +1138,8 @@ function summarizeStageExecutions(events) {
|
|
|
873
1138
|
endsById.get(end.event_id).push(end);
|
|
874
1139
|
}
|
|
875
1140
|
|
|
1141
|
+
// N4: event_id 配对——cmdStart 默认每次生成新 event_id,cmdEnd 用 --event-id 配对;
|
|
1142
|
+
// 若手动传同 --event-id 重跑 start 会产出同 id 多 start,配对脆弱(两个 attempt 共享同一 end)。cmdStart 默认行为已规避。
|
|
876
1143
|
const attempts = starts.map(start => {
|
|
877
1144
|
const end = latestByTimestamp(endsById.get(start.event_id) || []);
|
|
878
1145
|
return {
|
|
@@ -904,12 +1171,10 @@ function summarizeStageExecutions(events) {
|
|
|
904
1171
|
let unresolvedOpenStages = 0;
|
|
905
1172
|
|
|
906
1173
|
for (const groupAttempts of groups.values()) {
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return attempt.end === latestEnd;
|
|
912
|
-
})
|
|
1174
|
+
// canonical:同 key 取最晚 end 的 attempt(不限 result,含 partial/failure)—— 全 partial 场景也需 canonical 驱动 rework 判定
|
|
1175
|
+
const endedAttempts = groupAttempts.filter(attempt => attempt.end);
|
|
1176
|
+
const canonical = endedAttempts.length
|
|
1177
|
+
? endedAttempts.reduce((a, b) => timestampMs(a.end) >= timestampMs(b.end) ? a : b)
|
|
913
1178
|
: null;
|
|
914
1179
|
if (canonical) {
|
|
915
1180
|
canonical.canonical = true;
|
|
@@ -930,7 +1195,17 @@ function summarizeStageExecutions(events) {
|
|
|
930
1195
|
if (attempt === canonical) continue;
|
|
931
1196
|
|
|
932
1197
|
if (canonical && timestampMs(attempt.start) <= timestampMs(canonical.end || canonical.start)) {
|
|
933
|
-
attempt.
|
|
1198
|
+
if (attempt.end && attempt.end.rework_reason) {
|
|
1199
|
+
attempt.rework_reason = attempt.end.rework_reason;
|
|
1200
|
+
} else if (!attempt.end) {
|
|
1201
|
+
attempt.rework_reason = 'incomplete';
|
|
1202
|
+
} else if (attempt.result === 'failure' || attempt.result === 'partial') {
|
|
1203
|
+
attempt.rework_reason = 'prev-failed';
|
|
1204
|
+
} else if (canonical.result !== 'success') {
|
|
1205
|
+
attempt.rework_reason = 'prev-failed';
|
|
1206
|
+
} else {
|
|
1207
|
+
attempt.rework_reason = 'completed_rework_success';
|
|
1208
|
+
}
|
|
934
1209
|
reworkAttempts.push(attempt);
|
|
935
1210
|
bucket.rework_attempts += 1;
|
|
936
1211
|
if (attempt.end) {
|
|
@@ -1020,7 +1295,7 @@ function getFormalStageRelatedEvents(events, attempt, predicate) {
|
|
|
1020
1295
|
return [];
|
|
1021
1296
|
}
|
|
1022
1297
|
|
|
1023
|
-
function compactReworkSummary(summary) {
|
|
1298
|
+
function compactReworkSummary(summary, reasons) {
|
|
1024
1299
|
return {
|
|
1025
1300
|
total_attempts: summary.total_attempts,
|
|
1026
1301
|
canonical_attempts: summary.canonical_attempts,
|
|
@@ -1032,7 +1307,95 @@ function compactReworkSummary(summary) {
|
|
|
1032
1307
|
rework_stage_duration_ms: summary.rework_stage_duration_ms,
|
|
1033
1308
|
total_stage_duration_ms: summary.total_stage_duration_ms,
|
|
1034
1309
|
by_stage: summary.by_stage,
|
|
1310
|
+
reasons: reasons || { by_category: createReasonCategory(), details: [] },
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// report-time git 推导返工原因:对 summarizeStageExecutions 产出的 reworkAttempts 细化
|
|
1315
|
+
// - agent override 优先(stage_end.rework_reason 由 cmdEnd --reason 写入,summarizeStageExecutions 已保留)
|
|
1316
|
+
// - completed_rework_success(上次成功却重跑)按 git 推导:有 commit since end → code-changed-after-pass;
|
|
1317
|
+
// 无 commit 但 working tree 未提交 → code-changed-after-pass(has_uncommitted);都无 → recheck-no-change
|
|
1318
|
+
// - no git repo → unspecified
|
|
1319
|
+
// 注:用 `git log --all --since` 取所有 ref 上 commit date >= end.timestamp 的提交;reset 掉的 unreachable
|
|
1320
|
+
// commit 不计入(完全实现需 reflog,复杂且会 expire,本期务实按 ref 可达)
|
|
1321
|
+
function deriveReworkReasons(executionSummary, projectRoot) {
|
|
1322
|
+
const byCategory = createReasonCategory();
|
|
1323
|
+
const details = [];
|
|
1324
|
+
const reworkAttempts = executionSummary.reworkAttempts || [];
|
|
1325
|
+
const gitAvailable = projectRoot ? runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']) === 'true' : false;
|
|
1326
|
+
let uncommittedCache = null;
|
|
1327
|
+
const hasUncommitted = () => {
|
|
1328
|
+
if (uncommittedCache !== null) return uncommittedCache;
|
|
1329
|
+
if (!gitAvailable) { uncommittedCache = false; return false; }
|
|
1330
|
+
const status = runGit(projectRoot, ['status', '--porcelain']);
|
|
1331
|
+
uncommittedCache = Boolean(status && status.trim());
|
|
1332
|
+
return uncommittedCache;
|
|
1035
1333
|
};
|
|
1334
|
+
const bucketByKey = new Map();
|
|
1335
|
+
for (const bucket of executionSummary.summary.by_stage || []) {
|
|
1336
|
+
if (!bucket.reasons_by_category) bucket.reasons_by_category = createReasonCategory();
|
|
1337
|
+
bucketByKey.set(`${bucket.command}|${bucket.capability || ''}`, bucket);
|
|
1338
|
+
}
|
|
1339
|
+
for (const attempt of reworkAttempts) {
|
|
1340
|
+
let reason = attempt.rework_reason;
|
|
1341
|
+
let detail = null;
|
|
1342
|
+
let hasUncommittedFlag = false;
|
|
1343
|
+
if (reason === 'completed_rework_success') {
|
|
1344
|
+
if (!gitAvailable) {
|
|
1345
|
+
reason = 'unspecified';
|
|
1346
|
+
} else {
|
|
1347
|
+
const endTs = attempt.end && attempt.end.timestamp ? Date.parse(attempt.end.timestamp) : null;
|
|
1348
|
+
let recentCommitMessage = null;
|
|
1349
|
+
if (endTs !== null && Number.isFinite(endTs)) {
|
|
1350
|
+
const sinceIso = new Date(endTs).toISOString();
|
|
1351
|
+
const log = runGit(projectRoot, ['log', '--all', '--since', sinceIso, '--format=%H%x09%cI%x09%s']);
|
|
1352
|
+
if (log) {
|
|
1353
|
+
const lines = log.split(/\r?\n/).filter(Boolean);
|
|
1354
|
+
if (lines.length > 0) {
|
|
1355
|
+
const parts = lines[0].split('\t');
|
|
1356
|
+
recentCommitMessage = parts[2] || '';
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const uncommitted = hasUncommitted();
|
|
1361
|
+
if (recentCommitMessage !== null) {
|
|
1362
|
+
reason = 'code-changed-after-pass';
|
|
1363
|
+
detail = recentCommitMessage;
|
|
1364
|
+
hasUncommittedFlag = uncommitted;
|
|
1365
|
+
} else if (uncommitted) {
|
|
1366
|
+
reason = 'code-changed-after-pass';
|
|
1367
|
+
hasUncommittedFlag = true;
|
|
1368
|
+
} else {
|
|
1369
|
+
reason = 'recheck-no-change';
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// T3.6 + Q6: check 在 test 之后重跑(归档前强制复检)→ pre-archive-recheck(不论原 reason)
|
|
1374
|
+
// 判定:attempt 为 check 且 check 的 canonical 开始时间晚于 test 的 canonical 结束时间 → 归档前强制复检
|
|
1375
|
+
if (attempt.command === 'check') {
|
|
1376
|
+
const canonicalCheck = getCanonicalAttempt(executionSummary, 'check', attempt.capability);
|
|
1377
|
+
// Y2 修复:test 也传 capability 避免跨 capability 取到错误 canonical(summarizeStageExecutions 按 command|capability 分桶)
|
|
1378
|
+
const canonicalTest = getCanonicalAttempt(executionSummary, 'test', attempt.capability);
|
|
1379
|
+
if (canonicalCheck && canonicalCheck.start && canonicalTest && canonicalTest.end
|
|
1380
|
+
&& timestampMs(canonicalCheck.start) > timestampMs(canonicalTest.end)) {
|
|
1381
|
+
reason = 'pre-archive-recheck';
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
attempt.rework_reason = reason;
|
|
1385
|
+
byCategory[reason] = (byCategory[reason] || 0) + 1;
|
|
1386
|
+
const bucket = bucketByKey.get(`${attempt.command}|${attempt.capability || ''}`);
|
|
1387
|
+
if (bucket) {
|
|
1388
|
+
bucket.reasons_by_category[reason] = (bucket.reasons_by_category[reason] || 0) + 1;
|
|
1389
|
+
}
|
|
1390
|
+
details.push({
|
|
1391
|
+
command: attempt.command,
|
|
1392
|
+
attempt_event_id: (attempt.start && attempt.start.event_id) || null,
|
|
1393
|
+
reason,
|
|
1394
|
+
detail,
|
|
1395
|
+
has_uncommitted: hasUncommittedFlag,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
return { by_category: byCategory, details };
|
|
1036
1399
|
}
|
|
1037
1400
|
|
|
1038
1401
|
function readSuccessSignal(event, detailKeys) {
|
|
@@ -1124,32 +1487,70 @@ function getTaskUpdateResult(event) {
|
|
|
1124
1487
|
if (!event || event.type !== 'task_update') return null;
|
|
1125
1488
|
const build = getBuildResults(event);
|
|
1126
1489
|
const test = getTestResults(event);
|
|
1490
|
+
const taskKind = event?.details?.task_kind || event?.task_kind || null;
|
|
1491
|
+
// P3: TDD 测试骨架任务(task_kind='test-skeleton')的红灯是预期(实现尚未编写),
|
|
1492
|
+
// failed>0 不计为成码失败;骨架按 TDD 节奏完成即视为一次成码,避免 E4 系统性失真。
|
|
1493
|
+
if (taskKind === 'test-skeleton') {
|
|
1494
|
+
return {
|
|
1495
|
+
task_id: event.task_id || null,
|
|
1496
|
+
task_kind: taskKind,
|
|
1497
|
+
success: true,
|
|
1498
|
+
build,
|
|
1499
|
+
test,
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1127
1502
|
const buildOk = build?.success;
|
|
1128
1503
|
const testOk = test ? test.failed === 0 : null;
|
|
1129
1504
|
const resultOk = event.result === 'success'
|
|
1130
1505
|
? true
|
|
1131
1506
|
: (event.result === 'failure' ? false : null);
|
|
1132
1507
|
const signals = [buildOk, testOk, resultOk].filter(value => value != null);
|
|
1508
|
+
// Q3: 三信号均 null 时,若 status=completed 则兜底 success=true(agent 只记 status 也算 E4)
|
|
1509
|
+
const statusOk = event.status === 'completed' || event.details?.status === 'completed';
|
|
1133
1510
|
return {
|
|
1134
1511
|
task_id: event.task_id || null,
|
|
1135
|
-
|
|
1512
|
+
task_kind: taskKind,
|
|
1513
|
+
success: signals.length > 0 ? signals.every(Boolean) : (statusOk ? true : null),
|
|
1136
1514
|
build,
|
|
1137
1515
|
test,
|
|
1138
1516
|
};
|
|
1139
1517
|
}
|
|
1140
1518
|
|
|
1141
1519
|
function computeAiFirstPassMetrics(events) {
|
|
1520
|
+
// 仅对 implementation 任务计算一次成码率;test-skeleton 不计入分母
|
|
1521
|
+
// 未显式 task_kind 的 legacy 事件视为 implementation,保持向后兼容
|
|
1142
1522
|
const taskEvents = events
|
|
1143
|
-
.filter(e =>
|
|
1523
|
+
.filter(e => {
|
|
1524
|
+
if (e.type !== 'task_update' || !e.task_id) return false;
|
|
1525
|
+
const taskKind = e.details?.task_kind || e.task_kind;
|
|
1526
|
+
if (taskKind === 'test-skeleton') return false;
|
|
1527
|
+
return !taskKind || taskKind === 'implementation';
|
|
1528
|
+
})
|
|
1144
1529
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
1145
|
-
|
|
1530
|
+
|
|
1531
|
+
// A3(v3): 从 telemetry_warning(task_update_reuse) 提取复用 task_ids,
|
|
1532
|
+
// 被标记 task 不计入 first_pass 分子(分母不变)→ E4 反映复用扣分。
|
|
1533
|
+
// 旧 warning 无 details.task_ids → 不扣分(向后兼容)
|
|
1534
|
+
const reusedTaskIds = new Set();
|
|
1535
|
+
for (const e of events) {
|
|
1536
|
+
if (e?.type !== 'telemetry_warning') continue;
|
|
1537
|
+
if (e.details?.warning !== 'task_update_reuse') continue;
|
|
1538
|
+
const ids = e.details?.task_ids;
|
|
1539
|
+
if (Array.isArray(ids)) {
|
|
1540
|
+
for (const id of ids) { if (id) reusedTaskIds.add(id); }
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// 按 task_id 取最终结果(时间最晚)
|
|
1545
|
+
const finalByTask = new Map();
|
|
1146
1546
|
for (const event of taskEvents) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1547
|
+
const existing = finalByTask.get(event.task_id);
|
|
1548
|
+
if (!existing || new Date(event.timestamp).getTime() > new Date(existing.timestamp).getTime()) {
|
|
1549
|
+
finalByTask.set(event.task_id, event);
|
|
1149
1550
|
}
|
|
1150
1551
|
}
|
|
1151
1552
|
|
|
1152
|
-
const taskResults = Array.from(
|
|
1553
|
+
const taskResults = Array.from(finalByTask.values())
|
|
1153
1554
|
.map(getTaskUpdateResult)
|
|
1154
1555
|
.filter(result => result?.success != null);
|
|
1155
1556
|
if (taskResults.length === 0) {
|
|
@@ -1158,15 +1559,23 @@ function computeAiFirstPassMetrics(events) {
|
|
|
1158
1559
|
first_pass_tasks: 0,
|
|
1159
1560
|
measured_tasks: 0,
|
|
1160
1561
|
failed_tasks: [],
|
|
1562
|
+
test_skeleton_tasks: 0,
|
|
1563
|
+
reused_task_ids: Array.from(reusedTaskIds),
|
|
1564
|
+
e4_reuse_impacted: false,
|
|
1161
1565
|
};
|
|
1162
1566
|
}
|
|
1163
1567
|
|
|
1164
|
-
|
|
1568
|
+
// A3(v3): 被复用标记的 task 从分子扣除(仍计入分母)→ E4 下降而非归零
|
|
1569
|
+
const passedTasks = taskResults.filter(result => result.success && !reusedTaskIds.has(result.task_id));
|
|
1570
|
+
const e4ReuseImpacted = reusedTaskIds.size > 0 && taskResults.some(r => reusedTaskIds.has(r.task_id));
|
|
1165
1571
|
return {
|
|
1166
1572
|
e4_ai_code_first_pass_rate: roundMetric(passedTasks.length / taskResults.length),
|
|
1167
1573
|
first_pass_tasks: passedTasks.length,
|
|
1168
1574
|
measured_tasks: taskResults.length,
|
|
1169
1575
|
failed_tasks: taskResults.filter(result => !result.success).map(result => result.task_id).filter(Boolean),
|
|
1576
|
+
test_skeleton_tasks: 0,
|
|
1577
|
+
reused_task_ids: Array.from(reusedTaskIds),
|
|
1578
|
+
e4_reuse_impacted: e4ReuseImpacted,
|
|
1170
1579
|
};
|
|
1171
1580
|
}
|
|
1172
1581
|
|
|
@@ -1239,23 +1648,39 @@ function computeSpecTestCoverageMetrics(events) {
|
|
|
1239
1648
|
const coverageEvents = getSpecTestCoverageEvents(events);
|
|
1240
1649
|
const latestCoverageEvent = latestByTimestamp(coverageEvents);
|
|
1241
1650
|
const coverage = getSpecTestCoverage(latestCoverageEvent);
|
|
1242
|
-
if (
|
|
1651
|
+
if (coverage) {
|
|
1652
|
+
return {
|
|
1653
|
+
q4_spec_driven_test_coverage: coverage.coverage_rate,
|
|
1654
|
+
scenario_counts: {
|
|
1655
|
+
total: coverage.total_scenarios,
|
|
1656
|
+
covered: coverage.covered_scenarios,
|
|
1657
|
+
partial: coverage.partial_scenarios,
|
|
1658
|
+
uncovered: coverage.uncovered_scenarios,
|
|
1659
|
+
},
|
|
1660
|
+
latest_review_at: latestCoverageEvent.timestamp || null,
|
|
1661
|
+
source: 'spec_test_coverage',
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
// P4: 无 spec_test_coverage 事件时,fallback 到 conformance_review 断言覆盖率(近似值,标注 source)
|
|
1665
|
+
const confReview = getConformanceReview(latestByTimestamp(getConformanceReviewEvents(events)));
|
|
1666
|
+
if (confReview && confReview.total > 0) {
|
|
1243
1667
|
return {
|
|
1244
|
-
q4_spec_driven_test_coverage:
|
|
1245
|
-
scenario_counts: {
|
|
1668
|
+
q4_spec_driven_test_coverage: roundMetric((confReview.matched + confReview.partial * 0.5) / confReview.total),
|
|
1669
|
+
scenario_counts: {
|
|
1670
|
+
total: confReview.total,
|
|
1671
|
+
covered: confReview.matched,
|
|
1672
|
+
partial: confReview.partial,
|
|
1673
|
+
uncovered: confReview.missed,
|
|
1674
|
+
},
|
|
1246
1675
|
latest_review_at: null,
|
|
1676
|
+
source: 'conformance-fallback',
|
|
1247
1677
|
};
|
|
1248
1678
|
}
|
|
1249
|
-
|
|
1250
1679
|
return {
|
|
1251
|
-
q4_spec_driven_test_coverage:
|
|
1252
|
-
scenario_counts: {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
partial: coverage.partial_scenarios,
|
|
1256
|
-
uncovered: coverage.uncovered_scenarios,
|
|
1257
|
-
},
|
|
1258
|
-
latest_review_at: latestCoverageEvent.timestamp || null,
|
|
1680
|
+
q4_spec_driven_test_coverage: null,
|
|
1681
|
+
scenario_counts: { total: 0, covered: 0, partial: 0, uncovered: 0 },
|
|
1682
|
+
latest_review_at: null,
|
|
1683
|
+
source: null,
|
|
1259
1684
|
};
|
|
1260
1685
|
}
|
|
1261
1686
|
|
|
@@ -1268,7 +1693,7 @@ function normalizeConformanceStatus(value) {
|
|
|
1268
1693
|
return null;
|
|
1269
1694
|
}
|
|
1270
1695
|
|
|
1271
|
-
function getConformanceReview(event) {
|
|
1696
|
+
function getConformanceReview(event, applyAgentType) {
|
|
1272
1697
|
const review = event?.details?.conformance_review || event?.details?.conformance;
|
|
1273
1698
|
if (!review) return null;
|
|
1274
1699
|
|
|
@@ -1295,6 +1720,13 @@ function getConformanceReview(event) {
|
|
|
1295
1720
|
const total = matched + partial + missed;
|
|
1296
1721
|
const computedScore = total > 0 ? roundMetric((matched + partial * 0.5) / total) : null;
|
|
1297
1722
|
|
|
1723
|
+
// reviewer 字段:优先取 review.reviewer,否则 fallback 到事件 agent_type
|
|
1724
|
+
const reviewer = review.reviewer || event?.agent_type || event?.source || null;
|
|
1725
|
+
let reviewerIndependence = null;
|
|
1726
|
+
if (reviewer && applyAgentType) {
|
|
1727
|
+
reviewerIndependence = reviewer === applyAgentType ? 'self-review' : 'independent-review';
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1298
1730
|
return {
|
|
1299
1731
|
total,
|
|
1300
1732
|
matched,
|
|
@@ -1302,8 +1734,11 @@ function getConformanceReview(event) {
|
|
|
1302
1734
|
missed,
|
|
1303
1735
|
score: Number.isFinite(review.score) ? review.score : computedScore,
|
|
1304
1736
|
method: review.method || null,
|
|
1305
|
-
reviewer
|
|
1306
|
-
|
|
1737
|
+
reviewer,
|
|
1738
|
+
reviewer_independence: reviewerIndependence,
|
|
1739
|
+
// T3.4/T3.5: review 级 human_status — 仅当真实人工评审(review.human_status==='matched')才 matched,否则 unverified(AI 自评)
|
|
1740
|
+
human_status: review.human_status === 'matched' ? 'matched' : 'unverified',
|
|
1741
|
+
agent_confirmed: review.agent_confirmed === true || review.manual_confirmed === true || normalizedAssertions.some(a => a.human_status),
|
|
1307
1742
|
assertions: normalizedAssertions,
|
|
1308
1743
|
};
|
|
1309
1744
|
}
|
|
@@ -1319,12 +1754,19 @@ function getConformanceReviewEvents(events) {
|
|
|
1319
1754
|
function computeConformanceMetrics(events) {
|
|
1320
1755
|
const reviewEvents = getConformanceReviewEvents(events);
|
|
1321
1756
|
const latestReviewEvent = latestByTimestamp(reviewEvents);
|
|
1322
|
-
|
|
1757
|
+
// 取 apply 阶段最新的 agent_type 作为 reviewer 独立性比较基准
|
|
1758
|
+
const applyEvents = events.filter(e => !e.orphan && (e.command === 'apply' || e.stage === 'apply'));
|
|
1759
|
+
const latestApply = latestByTimestamp(applyEvents);
|
|
1760
|
+
const applyAgentType = latestApply?.agent_type || latestApply?.agent;
|
|
1761
|
+
const latestReview = getConformanceReview(latestReviewEvent, applyAgentType);
|
|
1323
1762
|
if (!latestReview) {
|
|
1324
1763
|
return {
|
|
1325
1764
|
q1_spec_conformance_score: null,
|
|
1326
1765
|
conformance_counts: { total: 0, matched: 0, partial: 0, missed: 0 },
|
|
1327
|
-
|
|
1766
|
+
agent_confirmed: false,
|
|
1767
|
+
reviewer: null,
|
|
1768
|
+
reviewer_independence: null,
|
|
1769
|
+
human_status: null,
|
|
1328
1770
|
latest_review_at: null,
|
|
1329
1771
|
};
|
|
1330
1772
|
}
|
|
@@ -1337,13 +1779,17 @@ function computeConformanceMetrics(events) {
|
|
|
1337
1779
|
partial: latestReview.partial,
|
|
1338
1780
|
missed: latestReview.missed,
|
|
1339
1781
|
},
|
|
1340
|
-
|
|
1782
|
+
agent_confirmed: latestReview.agent_confirmed,
|
|
1783
|
+
reviewer: latestReview.reviewer,
|
|
1784
|
+
reviewer_independence: latestReview.reviewer_independence,
|
|
1785
|
+
human_status: latestReview.human_status,
|
|
1341
1786
|
latest_review_at: latestReviewEvent.timestamp || null,
|
|
1342
1787
|
};
|
|
1343
1788
|
}
|
|
1344
1789
|
|
|
1345
1790
|
function getAiAdoptionReview(event) {
|
|
1346
|
-
|
|
1791
|
+
// Q4: 兼容三 key(ai_adoption / ai_code_adoption / ai_adoption_review)
|
|
1792
|
+
const review = event?.details?.ai_adoption || event?.details?.ai_code_adoption || event?.details?.ai_adoption_review;
|
|
1347
1793
|
if (!review) return null;
|
|
1348
1794
|
|
|
1349
1795
|
const retainedLines = Number.isFinite(review.retained_lines) ? review.retained_lines : null;
|
|
@@ -1385,7 +1831,8 @@ function getAiAdoptionEvents(events) {
|
|
|
1385
1831
|
return events.filter(e => {
|
|
1386
1832
|
return e.type === 'ai_adoption_review'
|
|
1387
1833
|
|| Boolean(e.details?.ai_adoption)
|
|
1388
|
-
|| Boolean(e.details?.ai_code_adoption)
|
|
1834
|
+
|| Boolean(e.details?.ai_code_adoption)
|
|
1835
|
+
|| Boolean(e.details?.ai_adoption_review);
|
|
1389
1836
|
});
|
|
1390
1837
|
}
|
|
1391
1838
|
|
|
@@ -1527,16 +1974,41 @@ function computeGitDocumentMetrics(projectRoot, changeName) {
|
|
|
1527
1974
|
|
|
1528
1975
|
const isRepo = runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']);
|
|
1529
1976
|
if (isRepo !== 'true') {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1977
|
+
// 无 git 时 fallback 到 doc_update 事件按文件去重计数,并标注 source
|
|
1978
|
+
try {
|
|
1979
|
+
const dataDir = getDataDir(projectRoot);
|
|
1980
|
+
const events = readEvents(dataDir, changeName);
|
|
1981
|
+
const docFiles = new Set();
|
|
1982
|
+
for (const e of events) {
|
|
1983
|
+
if (e.type === 'doc_update' && e.change === changeName && e.details && e.details.file) {
|
|
1984
|
+
docFiles.add(e.details.file);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
const iterationCount = docFiles.size > 0 ? docFiles.size : null;
|
|
1988
|
+
return {
|
|
1989
|
+
git_available: false,
|
|
1990
|
+
spec_iteration_count: iterationCount,
|
|
1991
|
+
document_commit_count: iterationCount,
|
|
1992
|
+
total_added_lines: null,
|
|
1993
|
+
total_deleted_lines: null,
|
|
1994
|
+
files: createDocFileMetrics(),
|
|
1995
|
+
diff_trend: [],
|
|
1996
|
+
source: iterationCount != null ? 'event-fallback' : null,
|
|
1997
|
+
warnings: ['当前项目不是 Git 仓库,已 fallback 到 doc_update 事件按文件去重计数'],
|
|
1998
|
+
};
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
return {
|
|
2001
|
+
git_available: false,
|
|
2002
|
+
spec_iteration_count: null,
|
|
2003
|
+
document_commit_count: null,
|
|
2004
|
+
total_added_lines: null,
|
|
2005
|
+
total_deleted_lines: null,
|
|
2006
|
+
files: createDocFileMetrics(),
|
|
2007
|
+
diff_trend: [],
|
|
2008
|
+
source: null,
|
|
2009
|
+
warnings: ['当前项目不是 Git 仓库,无法统计文档迭代'],
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
1540
2012
|
}
|
|
1541
2013
|
|
|
1542
2014
|
const changePath = `openspec/changes/${changeName}`;
|
|
@@ -1630,6 +2102,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
1630
2102
|
.filter(e => !options.change || e.change === options.change)
|
|
1631
2103
|
.filter(e => !options.capability || e.capability === options.capability);
|
|
1632
2104
|
const executionSummary = summarizeStageExecutions(scopedEvents);
|
|
2105
|
+
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
|
|
1633
2106
|
const starts = executionSummary.canonicalAttempts.map(attempt => attempt.start);
|
|
1634
2107
|
const ends = executionSummary.canonicalAttempts.map(attempt => attempt.end).filter(Boolean);
|
|
1635
2108
|
|
|
@@ -1660,16 +2133,20 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
1660
2133
|
return getCheckResults(e)?.consistency_score != null;
|
|
1661
2134
|
}));
|
|
1662
2135
|
const latestCheckResults = getCheckResults(latestCheckWithScore);
|
|
2136
|
+
// P0-3: check_result 事件 details 缺 consistency_score 时,fallback 读 state/check-result-<change>.json
|
|
2137
|
+
const consistencyFromState = !latestCheckResults && options.projectRoot && options.change
|
|
2138
|
+
? (readCheckResultState(options.projectRoot, options.change)?.check_results?.consistency_score ?? null)
|
|
2139
|
+
: null;
|
|
2140
|
+
const consistencyScore = latestCheckResults?.consistency_score ?? consistencyFromState ?? null;
|
|
1663
2141
|
|
|
1664
|
-
const stageSpecIterationCount = starts.filter(e => {
|
|
1665
|
-
return ['propose', 'spec', 'design', 'task'].includes(e.command);
|
|
1666
|
-
}).length;
|
|
1667
2142
|
const gitDocumentMetrics = options.projectRoot && options.change
|
|
1668
2143
|
? computeGitDocumentMetrics(options.projectRoot, options.change)
|
|
1669
2144
|
: null;
|
|
2145
|
+
// N5: 无 Git 提交历史时 P1=null(不适用),不再 fallback 到阶段 start 数(阶段数≠迭代次数,语义错误)
|
|
1670
2146
|
const specIterationCount = gitDocumentMetrics?.spec_iteration_count != null
|
|
1671
2147
|
? gitDocumentMetrics.spec_iteration_count
|
|
1672
|
-
:
|
|
2148
|
+
: null;
|
|
2149
|
+
const specIterationSource = gitDocumentMetrics?.source || (gitDocumentMetrics?.git_available ? 'git' : null);
|
|
1673
2150
|
|
|
1674
2151
|
const firstApply = firstByTimestamp(starts.filter(e => e.command === 'apply'));
|
|
1675
2152
|
const qualityGateBeforeApply = firstApply
|
|
@@ -1680,9 +2157,10 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
1680
2157
|
return results?.total > 0 && results.fixed_before_apply != null;
|
|
1681
2158
|
}));
|
|
1682
2159
|
const fixRateResults = getCheckResults(latestCheckWithFixRate);
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
2160
|
+
// Q2: P4 主指标改为"apply 前是否 check"(布尔→1/0),不再依赖 fixRateResults
|
|
2161
|
+
// fixed_before_apply/total 作为辅助指标 p4b_check_fixed_rate,不覆盖 P4
|
|
2162
|
+
const qualityGateRate = qualityGateBeforeApply == null ? null : (qualityGateBeforeApply ? 1 : 0);
|
|
2163
|
+
const fixRate = fixRateResults ? roundMetric(fixRateResults.fixed_before_apply / fixRateResults.total) : null;
|
|
1686
2164
|
const conformanceMetrics = computeConformanceMetrics(scopedEvents);
|
|
1687
2165
|
const aiAdoptionMetrics = computeAiAdoptionMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
|
|
1688
2166
|
const aiFirstPassMetrics = computeAiFirstPassMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
|
|
@@ -1695,6 +2173,9 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
1695
2173
|
e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(specDuration / totalStageDuration) : null,
|
|
1696
2174
|
e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
|
|
1697
2175
|
effective_stage_duration_ms: executionSummary.summary.effective_stage_duration_ms,
|
|
2176
|
+
idle_time_ms: leadTime != null && executionSummary.summary.effective_stage_duration_ms != null
|
|
2177
|
+
? Math.max(0, leadTime - executionSummary.summary.effective_stage_duration_ms)
|
|
2178
|
+
: null,
|
|
1698
2179
|
rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
|
|
1699
2180
|
total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
|
|
1700
2181
|
e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
|
|
@@ -1706,21 +2187,27 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
1706
2187
|
},
|
|
1707
2188
|
quality: {
|
|
1708
2189
|
q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
|
|
2190
|
+
q1_human_status: conformanceMetrics.human_status,
|
|
1709
2191
|
q3_build_first_pass_rate: firstBuildSuccess == null ? null : (firstBuildSuccess ? 1 : 0),
|
|
1710
2192
|
q4_spec_driven_test_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
|
|
1711
|
-
|
|
2193
|
+
q4_source: specTestCoverageMetrics.source,
|
|
2194
|
+
q5_cross_doc_consistency_score: consistencyScore,
|
|
1712
2195
|
conformance_counts: conformanceMetrics.conformance_counts,
|
|
1713
|
-
|
|
2196
|
+
conformance_agent_confirmed: conformanceMetrics.agent_confirmed,
|
|
2197
|
+
conformance_reviewer: conformanceMetrics.reviewer,
|
|
2198
|
+
conformance_reviewer_independence: conformanceMetrics.reviewer_independence,
|
|
1714
2199
|
spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
|
|
1715
2200
|
},
|
|
1716
2201
|
process: {
|
|
1717
2202
|
p1_spec_iteration_count: specIterationCount,
|
|
2203
|
+
p1_spec_iteration_source: specIterationSource,
|
|
1718
2204
|
p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
|
|
1719
2205
|
p2_ai_code_adoption_level: aiAdoptionMetrics.adoption_level,
|
|
1720
2206
|
ai_adoption_counts: aiAdoptionMetrics.adoption_counts,
|
|
1721
2207
|
p4_quality_gate_enforcement_rate: qualityGateRate,
|
|
2208
|
+
p4b_check_fixed_rate: fixRate,
|
|
1722
2209
|
git_document_metrics: gitDocumentMetrics,
|
|
1723
|
-
rework_summary: compactReworkSummary(executionSummary.summary),
|
|
2210
|
+
rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
|
|
1724
2211
|
},
|
|
1725
2212
|
manual_insights: computeManualInsightMetrics(scopedEvents),
|
|
1726
2213
|
telemetry_health: computeTelemetryHealthMetrics(scopedEvents),
|
|
@@ -1787,6 +2274,7 @@ function computePdfMvpMetrics(events, options = {}) {
|
|
|
1787
2274
|
e2_coding_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e2_coding_time_ratio),
|
|
1788
2275
|
e3_spec_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e3_spec_time_ratio),
|
|
1789
2276
|
e4_ai_code_first_pass_rate: averageMetric(changeMetrics, m => m.efficiency.e4_ai_code_first_pass_rate),
|
|
2277
|
+
idle_time_ms: averageMetric(changeMetrics, m => m.efficiency.idle_time_ms),
|
|
1790
2278
|
},
|
|
1791
2279
|
quality: {
|
|
1792
2280
|
q1_spec_conformance_score: averageMetric(changeMetrics, m => m.quality.q1_spec_conformance_score),
|
|
@@ -1844,9 +2332,36 @@ function renderPdfMvpMarkdown(metrics) {
|
|
|
1844
2332
|
].join('\n');
|
|
1845
2333
|
}
|
|
1846
2334
|
|
|
2335
|
+
function formatMsHuman(ms) {
|
|
2336
|
+
if (ms == null) return 'null';
|
|
2337
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
|
|
2338
|
+
if (ms < 3600000) return `≈${Math.round(ms / 60000)}分钟`;
|
|
2339
|
+
return `≈${Math.floor(ms / 3600000)}小时${Math.round((ms % 3600000) / 60000)}分钟`;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* 格式化毫秒为秒数 + 人类可读时长
|
|
2344
|
+
* 输出格式:`{秒} s({人类可读})`,例如 `4482.91 s(≈1小时15分钟)`
|
|
2345
|
+
* ⚠️ 非向后兼容变更:旧版本仅输出 `{秒} s`,新版本追加人类可读括号。
|
|
2346
|
+
* 如有下游脚本解析此输出,需适配新的括号格式。
|
|
2347
|
+
*/
|
|
1847
2348
|
function formatMsToSeconds(ms) {
|
|
1848
2349
|
if (ms == null) return 'null';
|
|
1849
|
-
return `${(ms / 1000).toFixed(2)} s
|
|
2350
|
+
return `${(ms / 1000).toFixed(2)} s(${formatMsHuman(ms)})`;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* 格式化毫秒为分钟(HTML 报告时间单位用)。返回 { minutes, sub } 或 null。
|
|
2355
|
+
* <60 分钟:sub=null(只显示「N 分钟」);≥60 分钟:sub="≈X小时Y分钟"。
|
|
2356
|
+
* 例如 3931000ms → { minutes:66, sub:"≈1小时6分钟" };1762000ms → { minutes:29, sub:null }。
|
|
2357
|
+
*/
|
|
2358
|
+
function formatDuration(ms) {
|
|
2359
|
+
if (ms == null || !Number.isFinite(ms)) return null;
|
|
2360
|
+
const minutes = Math.round(ms / 60000);
|
|
2361
|
+
if (minutes < 60) return { minutes, sub: null };
|
|
2362
|
+
const hours = Math.floor(minutes / 60);
|
|
2363
|
+
const mins = minutes % 60;
|
|
2364
|
+
return { minutes, sub: `≈${hours}小时${mins}分钟` };
|
|
1850
2365
|
}
|
|
1851
2366
|
|
|
1852
2367
|
function formatRatioToPercent(ratio) {
|
|
@@ -1854,6 +2369,16 @@ function formatRatioToPercent(ratio) {
|
|
|
1854
2369
|
return `${(ratio * 100).toFixed(1)}%`;
|
|
1855
2370
|
}
|
|
1856
2371
|
|
|
2372
|
+
function formatReworkReasons(reasons) {
|
|
2373
|
+
if (!reasons || !reasons.by_category) return 'null';
|
|
2374
|
+
return REWORK_REASON_CATEGORIES.map(c => `${REWORK_REASON_CN[c] || c}=${reasons.by_category[c] ?? 0}`).join(', ');
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function formatReworkReasonDetails(reasons) {
|
|
2378
|
+
if (!reasons || !Array.isArray(reasons.details) || reasons.details.length === 0) return [];
|
|
2379
|
+
return reasons.details.map(d => `- ${d.command} → ${REWORK_REASON_CN[d.reason] || d.reason}${d.detail ? ` [${String(d.detail).replace(/[\r\n\]]/g, ' ')}]` : ''}${d.has_uncommitted ? '(含未提交变更)' : ''}`);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
1857
2382
|
function renderExecutiveReportMarkdown(report) {
|
|
1858
2383
|
const metrics = report.metrics;
|
|
1859
2384
|
const health = report.doctor;
|
|
@@ -1862,35 +2387,84 @@ function renderExecutiveReportMarkdown(report) {
|
|
|
1862
2387
|
return [
|
|
1863
2388
|
`# SDD 效果度量报告${report.change ? ` - ${report.change}` : ''}`,
|
|
1864
2389
|
'',
|
|
1865
|
-
`- 生成时间:${report.generated_at}`,
|
|
1866
|
-
`- 项目路径:${report.project_root}`,
|
|
2390
|
+
`- 生成时间:${formatLocalTimestamp(report.generated_at)}`,
|
|
2391
|
+
`- 项目路径:${report.project_root ? String(report.project_root).replace(/[\\/]+$/, '').split(/[\\/]/).pop() : 'null'}`,
|
|
1867
2392
|
`- 统计范围:${report.change ? `change/${report.change}` : 'project'}`,
|
|
1868
2393
|
'',
|
|
1869
2394
|
'## 执行摘要',
|
|
1870
2395
|
`- Telemetry 健康分:${formatRatioToPercent(health.telemetry_health_score)}(0-100%,反映遥测数据的完整性,扣分项包括未闭环阶段、孤儿事件等)`,
|
|
1871
2396
|
`- 阶段闭环率:${formatRatioToPercent(health.matched_stage_rate)}(已配对 start/end 的阶段占有效阶段总数的比例)`,
|
|
1872
|
-
`- 严重问题数:${health.severe_issues?.length
|
|
2397
|
+
`- 严重问题数:${health.severe_issues?.length ?? '无数据'}(存在未闭环阶段、孤儿事件或未知命令等阻断级问题的数量)`,
|
|
1873
2398
|
'',
|
|
1874
2399
|
'## 效率指标',
|
|
1875
2400
|
`- E1 需求到归档总时长:${formatMsToSeconds(metrics.efficiency?.e1_lead_time_ms)}`,
|
|
1876
|
-
`- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}
|
|
2401
|
+
`- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}(仅 apply 阶段;test 阶段耗时另见有效阶段总耗时)`,
|
|
1877
2402
|
`- E3 规约时间占比:${formatRatioToPercent(metrics.efficiency?.e3_spec_time_ratio)}`,
|
|
1878
|
-
`- E4 AI 一次成码率:${formatRatioToPercent(metrics.efficiency?.e4_ai_code_first_pass_rate)}`,
|
|
2403
|
+
`- E4 AI 一次成码率:${formatRatioToPercent(metrics.efficiency?.e4_ai_code_first_pass_rate)}(基于 task_update 信号判定,非代码 diff)${report.telemetry_warnings?.by_type?.task_update_reuse > 0 ? '(E4 受 task_update 复用影响,已扣分)' : ''}`,
|
|
1879
2404
|
`- 有效阶段总耗时:${formatMsToSeconds(metrics.efficiency?.effective_stage_duration_ms)}`,
|
|
1880
|
-
`-
|
|
2405
|
+
`- 阶段间等待:${formatMsToSeconds(metrics.efficiency?.idle_time_ms)}`,
|
|
2406
|
+
`- 流程修正阶段总耗时:${formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms)}`,
|
|
1881
2407
|
'',
|
|
1882
2408
|
'## 质量指标',
|
|
1883
|
-
`- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}`,
|
|
2409
|
+
`- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}${metrics.quality?.q1_human_status === 'unverified' ? '(自评,待人工确认)' : ''}`,
|
|
1884
2410
|
`- Q3 构建一次通过率:${formatRatioToPercent(metrics.quality?.q3_build_first_pass_rate)}`,
|
|
1885
|
-
`- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}`,
|
|
1886
|
-
`- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}
|
|
2411
|
+
`- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}${metrics.quality?.q4_source === 'conformance-fallback' ? '(近似自 conformance_review)' : ''}`,
|
|
2412
|
+
`- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}(取最新 check 的 consistency_score)`,
|
|
1887
2413
|
'',
|
|
1888
2414
|
'## 过程指标',
|
|
1889
|
-
`- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}`,
|
|
2415
|
+
`- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}${metrics.process?.p1_spec_iteration_source ? `(来源:${metrics.process.p1_spec_iteration_source})` : ''}`,
|
|
1890
2416
|
`- P2 AI 代码保留率:${formatRatioToPercent(metrics.process?.p2_ai_code_adoption_rate)}`,
|
|
1891
2417
|
`- P4 质量门前置率:${formatRatioToPercent(metrics.process?.p4_quality_gate_enforcement_rate)}`,
|
|
1892
|
-
`-
|
|
1893
|
-
`-
|
|
2418
|
+
`- 阶段重复执行次数:${metrics.process?.rework_summary?.total_rework_attempts ?? 'null'}`,
|
|
2419
|
+
`- 被覆盖的未闭环阶段数:${metrics.process?.rework_summary?.superseded_open_stages ?? 'null'}(start 后未 end,被后续成功执行覆盖)`,
|
|
2420
|
+
`- 重复执行原因分布:${formatReworkReasons(metrics.process?.rework_summary?.reasons)}`,
|
|
2421
|
+
...formatReworkReasonDetails(metrics.process?.rework_summary?.reasons),
|
|
2422
|
+
'',
|
|
2423
|
+
'## 变更文件',
|
|
2424
|
+
`- 数据可用:${report.changed_files?._available ? '是' : '否'}`,
|
|
2425
|
+
`- 变更文件数:${report.changed_files?.files_changed ?? 'null'}${report.changed_files?.files_diff_note ? `(${report.changed_files.files_diff_note})` : ''}`,
|
|
2426
|
+
`- 新增行数:${report.changed_files?.added_lines ?? 'null'}`,
|
|
2427
|
+
`- 文件清单:${Array.isArray(report.changed_files?.files) ? report.changed_files.files.join(', ') : 'null'}`,
|
|
2428
|
+
'',
|
|
2429
|
+
'## 时间线',
|
|
2430
|
+
...(report.timeline?._available && Array.isArray(report.timeline.nodes) && report.timeline.nodes.length > 0
|
|
2431
|
+
? report.timeline.nodes.map(n => `- ${formatLocalTimestamp(n.timestamp)} [${n.type}] ${n.command || ''}${n.result ? ` → ${n.result}` : ''}`)
|
|
2432
|
+
: ['- 无时间线节点(数据不可用)']),
|
|
2433
|
+
'',
|
|
2434
|
+
'## 阶段状态',
|
|
2435
|
+
...(report.stage_status?._available && Array.isArray(report.stage_status.stages) && report.stage_status.stages.length > 0
|
|
2436
|
+
? report.stage_status.stages.map(s => `- ${s.command}${s.capability ? `/${s.capability}` : ''}:尝试 ${s.total_attempts} 次,成功 ${s.successful_attempts},重复执行 ${s.rework_attempts}`)
|
|
2437
|
+
: ['- 无阶段状态数据(数据不可用)']),
|
|
2438
|
+
'',
|
|
2439
|
+
'## 已知风险',
|
|
2440
|
+
`- 数据可用:${report.known_risks?._available ? '是' : '否'}`,
|
|
2441
|
+
`- 严重问题数:${report.known_risks?._available ? (report.known_risks.severe_issues?.length ?? 0) : '无数据'}`,
|
|
2442
|
+
...(Array.isArray(report.known_risks?.warnings) && report.known_risks.warnings.length > 0
|
|
2443
|
+
? report.known_risks.warnings.flatMap(w => {
|
|
2444
|
+
// T2.4: 渲染 warning_items 明细;旧事件无 items 时降级为数量(DC-101)
|
|
2445
|
+
const items = Array.isArray(w.items) ? w.items : null;
|
|
2446
|
+
const count = items ? items.length : (w.warnings || 0);
|
|
2447
|
+
// Q7: 标注已修复/待确认
|
|
2448
|
+
const statusLabel = w.resolved === true ? '✅已修复' : '⏳待确认';
|
|
2449
|
+
const head = `- 警告:${w.command || 'check'} 阶段 ${count} 个${statusLabel}`;
|
|
2450
|
+
if (!items || items.length === 0) return [`${head}(无明细)`];
|
|
2451
|
+
return [head, ...items.map(it => ` - ${it.description || '待确认项'}${it.target ? `(${it.target})` : ''}`)];
|
|
2452
|
+
})
|
|
2453
|
+
: ['- 无警告']),
|
|
2454
|
+
'',
|
|
2455
|
+
'## 过程记录',
|
|
2456
|
+
`- 数据可用:${report.process_notes?._available ? '是' : '否'}`,
|
|
2457
|
+
`- 过程事件总数:${report.process_notes?.total ?? 'null'}`,
|
|
2458
|
+
`- 按 kind 分布:${report.process_notes?.by_kind ? JSON.stringify(report.process_notes.by_kind) : 'null'}`,
|
|
2459
|
+
...(Array.isArray(report.process_notes?.notes) && report.process_notes.notes.length > 0
|
|
2460
|
+
? report.process_notes.notes.map(n => `- [${n.stage}] ${n.kind}${n.round ? ` R${n.round}` : ''}:${n.summary}${n.target ? ` → ${n.target}` : ''}`)
|
|
2461
|
+
: ['- 无过程事件']),
|
|
2462
|
+
'',
|
|
2463
|
+
'## 产物清单',
|
|
2464
|
+
`- 数据可用:${report.artifacts?._available ? '是' : '否'}`,
|
|
2465
|
+
`- 归档目录:${report.artifacts?.archive_path ?? 'null'}`,
|
|
2466
|
+
`- 归档清单:${report.artifacts?.manifest_path ?? 'null'}`,
|
|
2467
|
+
`- 迁移文件数:${Array.isArray(report.artifacts?.copied_specs) ? report.artifacts.copied_specs.length : 'null'}`,
|
|
1894
2468
|
'',
|
|
1895
2469
|
'## 归档结果',
|
|
1896
2470
|
`- 归档原因:${archive?.reason || 'null'}`,
|
|
@@ -1898,37 +2472,674 @@ function renderExecutiveReportMarkdown(report) {
|
|
|
1898
2472
|
`- 归档目录:${archive?.archive_path || 'null'}`,
|
|
1899
2473
|
`- 归档清单:${archive?.manifest_path || 'null'}`,
|
|
1900
2474
|
`- 最终报告:${archive?.report_path || 'null'}`,
|
|
1901
|
-
`-
|
|
1902
|
-
`-
|
|
1903
|
-
`-
|
|
2475
|
+
`- 已完成任务项(含子项 checkbox):${taskCompletion?.completed ?? 'null'}`,
|
|
2476
|
+
`- 未勾选任务项(含子项 checkbox):${taskCompletion?.incomplete ?? 'null'}`,
|
|
2477
|
+
`- 任务项总数(含子项 checkbox):${taskCompletion?.total ?? 'null'}`,
|
|
1904
2478
|
'',
|
|
1905
2479
|
'## 说明',
|
|
1906
2480
|
'- `null` 表示当前还没有采集到对应事件或该指标暂不适用。',
|
|
1907
2481
|
'- E4 AI 一次成码率:依赖 `task_update` 事件中的 `task_id` 和首次执行结果,若 apply 阶段未正确记录则显示 null。',
|
|
1908
|
-
'- P1
|
|
2482
|
+
'- P1 文档迭代次数:依赖 Git 提交历史统计;无 Git 仓库时 fallback 到 doc_update 事件计数并标注来源。apply 阶段遵循 Git 只读策略不自动 commit 时,该 change 无提交历史 → P1=null 属预期(非数据缺失)。',
|
|
1909
2483
|
'- P2 AI 代码保留率:优先使用 `ai_adoption_review` 的 final 状态,无 final 时 fallback 到 AI 产出快照(ai_snapshot)。',
|
|
1910
|
-
'- Q4 规约驱动测试覆盖率:依赖
|
|
2484
|
+
'- Q4 规约驱动测试覆盖率:依赖 `spec_test_coverage`(spec 断言→测试用例映射)精确统计;无该事件时 fallback 到 conformance_review 断言覆盖率(近似值,报告标注 source=conformance-fallback)。',
|
|
1911
2485
|
'- Q1 规约符合度与人工反馈类指标属于评审信号,默认不作为强阻断门禁。',
|
|
2486
|
+
'- M1 stage 时长含等待/故障耗时(API 错误、换模型、用户等待),未单独度量;过程记录的 api-error/model-switch 计数可作时长虚高旁证。',
|
|
2487
|
+
'- 过程记录(process_note):阶段内修复/澄清/决策/API 故障/换模型等事件,由 agent 在关键节点记录,可统计可追溯。',
|
|
1912
2488
|
].join('\n');
|
|
1913
2489
|
}
|
|
1914
2490
|
|
|
2491
|
+
function renderExecutiveReportHtml(report) {
|
|
2492
|
+
const metrics = report.metrics || {};
|
|
2493
|
+
const eff = metrics.efficiency || {};
|
|
2494
|
+
const qual = metrics.quality || {};
|
|
2495
|
+
const proc = metrics.process || {};
|
|
2496
|
+
const health = report.doctor || {};
|
|
2497
|
+
const archive = report.archive_result || null;
|
|
2498
|
+
const taskCompletion = archive?.task_completion || null;
|
|
2499
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
2500
|
+
|
|
2501
|
+
// T2.5: 分钟单位时长(<60 分无副,≥60 分加「≈X小时Y分钟」副)
|
|
2502
|
+
const durHtml = (ms) => {
|
|
2503
|
+
const d = formatDuration(ms);
|
|
2504
|
+
if (!d) return '<span class="metric-val muted-null">null</span>';
|
|
2505
|
+
return `${d.minutes}<span class="unit">分钟</span>${d.sub ? `<span class="sub">${esc(d.sub)}</span>` : ''}`;
|
|
2506
|
+
};
|
|
2507
|
+
const pctHtml = (ratio, sub) => {
|
|
2508
|
+
if (ratio == null || !Number.isFinite(ratio)) return '<span class="metric-val muted-null">null</span>';
|
|
2509
|
+
return `${(ratio * 100).toFixed(1)}<span class="unit">%</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
|
|
2510
|
+
};
|
|
2511
|
+
const numHtml = (v, unit, sub) => {
|
|
2512
|
+
if (v == null) return '<span class="metric-val muted-null">null</span>';
|
|
2513
|
+
return `${esc(v)}${unit ? `<span class="unit">${esc(unit)}</span>` : ''}${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
|
|
2514
|
+
};
|
|
2515
|
+
// 指标卡:name + 值 + 通俗化说明(info-btn 点击弹窗,不泄露内部术语)
|
|
2516
|
+
const metricCell = (name, valHtml, info) => `<div class="metric"><div class="metric-head"><span class="metric-name">${esc(name)}</span>${info ? `<span class="info-btn" data-title="${esc(name)}" data-info="${esc(info)}">i</span>` : ''}</div><div class="metric-val">${valHtml}</div></div>`;
|
|
2517
|
+
|
|
2518
|
+
// 健康分环形仪表盘
|
|
2519
|
+
const hs = health.telemetry_health_score;
|
|
2520
|
+
const gaugePct = hs == null ? 0 : Math.max(0, Math.min(1, hs));
|
|
2521
|
+
const gaugeR = 40, gaugeC = 2 * Math.PI * gaugeR;
|
|
2522
|
+
const gaugeOffset = gaugeC * (1 - gaugePct);
|
|
2523
|
+
const gaugeColor = gaugePct >= 0.8 ? '#22c55e' : (gaugePct >= 0.6 ? '#f59e0b' : '#ef4444');
|
|
2524
|
+
const gaugeHtml = `<div class="gauge"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="${gaugeR}" class="gauge-track"/><circle cx="50" cy="50" r="${gaugeR}" class="gauge-fill" style="stroke:${gaugeColor};stroke-dasharray:${gaugeC.toFixed(3)};stroke-dashoffset:${gaugeOffset.toFixed(3)}"/></svg><div class="gauge-text">${hs == null ? 'N/A' : Math.round(gaugePct * 100) + '%'}</div></div>`;
|
|
2525
|
+
|
|
2526
|
+
// T2.3: 横向时间线(阶段级,从 stage_timeline 派生)
|
|
2527
|
+
const tlNodes = report.stage_timeline?._available && Array.isArray(report.stage_timeline.nodes) ? report.stage_timeline.nodes : [];
|
|
2528
|
+
const maxDur = tlNodes.reduce((m, n) => Math.max(m, n.duration_ms || 0), 0);
|
|
2529
|
+
const fmtHM = (ts) => {
|
|
2530
|
+
if (!ts) return '--:--';
|
|
2531
|
+
const d = new Date(ts);
|
|
2532
|
+
if (isNaN(d.getTime())) return '--:--';
|
|
2533
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
2534
|
+
};
|
|
2535
|
+
const tlHtml = tlNodes.length > 0
|
|
2536
|
+
? `<div class="timeline-h">${tlNodes.map((n, idx) => {
|
|
2537
|
+
const dur = n.duration_ms || 0;
|
|
2538
|
+
const barH = maxDur > 0 ? Math.max(6, Math.round((dur / maxDur) * 54)) : 6;
|
|
2539
|
+
const failed = n.result === 'failure';
|
|
2540
|
+
const short = !failed && dur > 0 && dur < 60000;
|
|
2541
|
+
const barCls = failed ? 'bad' : (short ? 'short' : 'ok');
|
|
2542
|
+
const round = n.round || 1;
|
|
2543
|
+
const nameSuffix = round > 1 ? '²' : '';
|
|
2544
|
+
const nameCls = failed ? ' style="color:#fca5a5"' : '';
|
|
2545
|
+
const durMin = dur > 0 ? Math.round(dur / 60000) : 0;
|
|
2546
|
+
const durLabel = durMin > 0 ? `${durMin}分` : '<1分';
|
|
2547
|
+
const taskLabel = n.task_count > 0 ? `·${n.task_count}任务` : '';
|
|
2548
|
+
const timeLabel = `${fmtHM(n.start_ts)}→${fmtHM(n.end_ts)}`;
|
|
2549
|
+
const repeatInfo = round > 1
|
|
2550
|
+
? `<div class="tl-repeat" data-title="重复执行:${esc(n.stage)}" data-info="该阶段是第 ${round} 次执行。首次未通过或被门禁拦截后重跑。">R</div>`
|
|
2551
|
+
: '';
|
|
2552
|
+
const connector = idx < tlNodes.length - 1 ? '<div class="tl-connector"></div>' : '';
|
|
2553
|
+
return `<div class="tl-node"><div class="tl-bar-wrap"><div class="tl-bar ${barCls}" style="height:${barH}px"></div></div><div class="tl-dot${failed ? ' bad' : ''}"></div><div class="tl-name"${nameCls}>${esc(n.stage)}${nameSuffix}${failed ? ' ✗' : ''}</div><div class="tl-dur">${durLabel}${taskLabel}</div><div class="tl-time">${timeLabel}</div>${repeatInfo}${connector}</div>`;
|
|
2554
|
+
}).join('')}</div>`
|
|
2555
|
+
: '<p class="muted">无时间线数据</p>';
|
|
2556
|
+
|
|
2557
|
+
// Q7: warn-status 从 archive task_completion 对比,incomplete=0 标"已修复"
|
|
2558
|
+
const warns = (report.known_risks?._available && Array.isArray(report.known_risks.warnings)) ? report.known_risks.warnings : [];
|
|
2559
|
+
const warnHtml = warns.length > 0
|
|
2560
|
+
? warns.map(w => {
|
|
2561
|
+
const items = Array.isArray(w.items) ? w.items : null;
|
|
2562
|
+
const count = items ? items.length : (w.warnings || 0);
|
|
2563
|
+
const detail = items && items.length > 0
|
|
2564
|
+
? items.map(it => it.target ? `${it.description || '待确认项'}(${it.target})` : (it.description || '待确认项')).join(';')
|
|
2565
|
+
: `共 ${count} 个待确认项(无明细)`;
|
|
2566
|
+
const resolved = w.resolved === true;
|
|
2567
|
+
const statusText = resolved ? '已修复' : '待确认';
|
|
2568
|
+
const statusStyle = resolved
|
|
2569
|
+
? 'background:rgba(34,197,94,.16);color:#86efac'
|
|
2570
|
+
: 'background:rgba(250,204,21,.16);color:#fde68a';
|
|
2571
|
+
return `<div class="warn-item"><div class="warn-icon">!</div><div class="warn-body"><div class="warn-title">${esc(w.command || 'check')} 阶段 ${count} 个待确认</div><div class="warn-detail">${esc(detail)}</div></div><span class="warn-status" style="${statusStyle}">${statusText}</span></div>`;
|
|
2572
|
+
}).join('')
|
|
2573
|
+
: `<div class="warn-item"><div class="warn-icon" style="background:rgba(96,165,250,.16);color:#bfdbfe">i</div><div class="warn-body"><div class="warn-title">严重问题:${health.severe_issues?.length || 0} 个</div><div class="warn-detail">无未闭环阶段、无孤儿事件、无未知命令(阻断级问题)</div></div><span class="warn-status" style="background:rgba(34,197,94,.16);color:#86efac">无需修复</span></div>`;
|
|
2574
|
+
|
|
2575
|
+
// 过程:重复执行原因
|
|
2576
|
+
const rs = proc.rework_summary || {};
|
|
2577
|
+
const reasonDetails = Array.isArray(rs.reasons?.details) ? rs.reasons.details : [];
|
|
2578
|
+
const reasonTag = (reason) => {
|
|
2579
|
+
if (reason === 'incomplete' || reason === 'prev-failed') return { cls: 'unfinished', text: reason === 'incomplete' ? '前次未完成' : '前次失败重跑' };
|
|
2580
|
+
if (reason === 'completed_rework_success') return { cls: 'rework', text: '通过后复检' };
|
|
2581
|
+
return { cls: 'rework', text: REWORK_REASON_CN[reason] || reason || '复检' };
|
|
2582
|
+
};
|
|
2583
|
+
const reasonHtml = reasonDetails.length > 0
|
|
2584
|
+
? `<div class="reasons">${reasonDetails.map(d => {
|
|
2585
|
+
const t = reasonTag(d.reason);
|
|
2586
|
+
const note = d.detail ? String(d.detail).replace(/[\r\n]/g, ' ').slice(0, 60) : '';
|
|
2587
|
+
return `<div class="reason-item"><span class="reason-stage">${esc(d.command)}</span><span class="reason-arrow">→</span><span class="reason-tag ${t.cls}">${esc(t.text)}</span>${note ? `<span class="reason-note">${esc(note)}</span>` : ''}</div>`;
|
|
2588
|
+
}).join('')}</div>`
|
|
2589
|
+
: '';
|
|
2590
|
+
const reworkTotal = rs.total_rework_attempts ?? 0;
|
|
2591
|
+
const byCat = rs.reasons?.by_category || {};
|
|
2592
|
+
const catLine = REWORK_REASON_CATEGORIES.map(c => `${REWORK_REASON_CN[c] || c}=${byCat[c] ?? 0}`).join(' · ');
|
|
2593
|
+
|
|
2594
|
+
// 项目名(basename,不写本机绝对路径 —— T3.7 报告路径 basename 的 HTML 侧落地)
|
|
2595
|
+
const projName = report.project_root ? String(report.project_root).replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '';
|
|
2596
|
+
const genTime = formatLocalTimestamp(report.generated_at);
|
|
2597
|
+
const severeCount = health.severe_issues?.length || 0;
|
|
2598
|
+
const matchedRate = health.matched_stage_rate;
|
|
2599
|
+
|
|
2600
|
+
// T3.2: 过程质量信号警示条(P4=0% / 过程记录缺失 / task_update 复用)—— 摘要区橙色警示
|
|
2601
|
+
const pqSignals = [];
|
|
2602
|
+
if (proc.p4_quality_gate_enforcement_rate === 0) {
|
|
2603
|
+
pqSignals.push('质量门前置率 0%(首次编码未先检查,被门禁拦下)');
|
|
2604
|
+
}
|
|
2605
|
+
const pnTotal = report.process_notes?._available ? (report.process_notes.total ?? 0) : 0;
|
|
2606
|
+
if (pnTotal === 0) {
|
|
2607
|
+
pqSignals.push('过程记录缺失(未采集到修复/决策/故障等过程事件)');
|
|
2608
|
+
}
|
|
2609
|
+
if (report.telemetry_warnings?.by_type?.task_update_reuse > 0) {
|
|
2610
|
+
pqSignals.push('检测到 task_update 复用(同一测试结果被多次记录)');
|
|
2611
|
+
}
|
|
2612
|
+
const qualityAlertHtml = pqSignals.length > 0
|
|
2613
|
+
? `<div class="quality-alert"><span class="qa-icon">!</span><div class="qa-body"><div class="qa-title">过程质量信号</div><div class="qa-items">${pqSignals.map(s => `<div>${esc(s)}</div>`).join('')}</div></div></div>`
|
|
2614
|
+
: '';
|
|
2615
|
+
|
|
2616
|
+
// 归档与产物
|
|
2617
|
+
const cf = report.changed_files || {};
|
|
2618
|
+
const arts = report.artifacts || {};
|
|
2619
|
+
const movedCount = Array.isArray(arts.copied_specs) ? arts.copied_specs.length : null;
|
|
2620
|
+
|
|
2621
|
+
return `<!doctype html>
|
|
2622
|
+
<html lang="zh-CN">
|
|
2623
|
+
<head>
|
|
2624
|
+
<meta charset="utf-8" />
|
|
2625
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2626
|
+
<title>SDD 效果度量报告${report.change ? ` - ${esc(report.change)}` : ''}</title>
|
|
2627
|
+
<style>
|
|
2628
|
+
:root{
|
|
2629
|
+
--bg:#0b1020;--card-bg:rgba(255,255,255,.05);--card-bg-2:rgba(255,255,255,.03);
|
|
2630
|
+
--border:rgba(255,255,255,.1);--border-2:rgba(255,255,255,.06);
|
|
2631
|
+
--text:#e5e7eb;--muted:#8b97a7;--muted-2:#64748b;
|
|
2632
|
+
--ok:#22c55e;--warn:#f59e0b;--bad:#ef4444;--accent:#60a5fa;
|
|
2633
|
+
}
|
|
2634
|
+
*{box-sizing:border-box}
|
|
2635
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;margin:0;background:var(--bg);color:var(--text);line-height:1.5;font-size:14px}
|
|
2636
|
+
main{max-width:1080px;margin:0 auto;padding:20px}
|
|
2637
|
+
.card{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin:12px 0;position:relative}
|
|
2638
|
+
h1{margin:0;font-size:22px;font-weight:600}
|
|
2639
|
+
h2{margin:0 0 14px;font-size:15px;font-weight:600;color:#cbd5e1;display:flex;align-items:center;gap:8px}
|
|
2640
|
+
h2::before{content:"";width:3px;height:14px;background:var(--accent);border-radius:2px}
|
|
2641
|
+
h3{margin:0;font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
2642
|
+
.muted{color:var(--muted)}
|
|
2643
|
+
.muted-2{color:var(--muted-2);font-size:11px}
|
|
2644
|
+
code{color:#bfdbfe;background:rgba(96,165,250,.1);padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
|
|
2645
|
+
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:600;font-size:11px}
|
|
2646
|
+
.badge.ok{background:rgba(34,197,94,.16);color:#86efac}
|
|
2647
|
+
.badge.warn{background:rgba(245,158,11,.18);color:#fcd34d}
|
|
2648
|
+
.badge.bad{background:rgba(239,68,68,.18);color:#fca5a5}
|
|
2649
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
2650
|
+
th,td{border-bottom:1px solid var(--border-2);padding:7px 8px;text-align:left;vertical-align:top}
|
|
2651
|
+
th{color:var(--muted);width:38%;font-weight:500}
|
|
2652
|
+
tr:last-child td,tr:last-child th{border-bottom:none}
|
|
2653
|
+
.header-row{display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:8px}
|
|
2654
|
+
.header-meta{font-size:11px;color:var(--muted-2);text-align:right}
|
|
2655
|
+
.summary{display:grid;grid-template-columns:auto 1fr 1fr;gap:14px;align-items:stretch}
|
|
2656
|
+
.summary .cell{background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;padding:14px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;min-height:110px}
|
|
2657
|
+
.gauge{width:96px;height:96px;position:relative}
|
|
2658
|
+
.gauge svg{transform:rotate(-90deg);width:100%;height:100%}
|
|
2659
|
+
.gauge-track{fill:none;stroke:rgba(255,255,255,.1);stroke-width:7}
|
|
2660
|
+
.gauge-fill{fill:none;stroke-width:7;stroke-linecap:round}
|
|
2661
|
+
.gauge-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700}
|
|
2662
|
+
.big-num{font-size:30px;font-weight:700;line-height:1}
|
|
2663
|
+
.big-num.ok{color:#86efac}
|
|
2664
|
+
.cell-label{font-size:11px;color:var(--muted);margin-top:6px;text-transform:uppercase;letter-spacing:.04em}
|
|
2665
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
|
|
2666
|
+
.metric{background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;padding:12px 12px 10px;position:relative;min-height:84px;display:flex;flex-direction:column;justify-content:space-between}
|
|
2667
|
+
.metric-head{display:flex;justify-content:space-between;align-items:flex-start;gap:6px}
|
|
2668
|
+
.metric-name{font-size:11px;color:var(--muted);font-weight:500;line-height:1.3}
|
|
2669
|
+
.metric-val{font-size:20px;font-weight:700;margin-top:6px;line-height:1.1}
|
|
2670
|
+
.metric-val .unit{font-size:12px;font-weight:400;color:var(--muted);margin-left:2px}
|
|
2671
|
+
.metric-val .sub{display:block;font-size:11px;font-weight:400;color:var(--muted-2);margin-top:2px}
|
|
2672
|
+
.metric-val.muted-null{color:var(--muted-2);font-size:16px}
|
|
2673
|
+
.info-btn{cursor:pointer;color:var(--accent);font-size:12px;background:rgba(96,165,250,.12);border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s;line-height:1;user-select:none}
|
|
2674
|
+
.info-btn:hover{background:rgba(96,165,250,.28)}
|
|
2675
|
+
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}
|
|
2676
|
+
.modal-mask.show{display:flex}
|
|
2677
|
+
.modal{background:#131a2e;border:1px solid var(--border);border-radius:12px;max-width:440px;width:100%;padding:18px 20px}
|
|
2678
|
+
.modal h4{margin:0 0 8px;font-size:14px;color:#cbd5e1}
|
|
2679
|
+
.modal p{margin:0;font-size:13px;color:var(--text);line-height:1.6}
|
|
2680
|
+
.modal .close{margin-top:14px;text-align:right}
|
|
2681
|
+
.modal .close button{background:rgba(96,165,250,.16);color:#bfdbfe;border:none;border-radius:6px;padding:5px 16px;font-size:12px;cursor:pointer}
|
|
2682
|
+
.timeline-h{display:flex;align-items:stretch;gap:0;overflow-x:auto;padding:4px 0 6px}
|
|
2683
|
+
.tl-node{flex:0 0 auto;min-width:88px;position:relative;display:flex;flex-direction:column;align-items:center;padding:0 2px}
|
|
2684
|
+
.tl-bar-wrap{width:100%;height:54px;display:flex;align-items:flex-end;justify-content:center}
|
|
2685
|
+
.tl-bar{width:100%;max-width:46px;border-radius:4px 4px 0 0;min-height:6px}
|
|
2686
|
+
.tl-bar.ok{background:linear-gradient(180deg,rgba(34,197,94,.85),rgba(34,197,94,.4))}
|
|
2687
|
+
.tl-bar.bad{background:linear-gradient(180deg,rgba(239,68,68,.85),rgba(239,68,68,.4))}
|
|
2688
|
+
.tl-bar.short{background:rgba(34,197,94,.35);min-height:6px}
|
|
2689
|
+
.tl-dot{width:10px;height:10px;border-radius:50%;margin-top:6px;background:var(--ok);border:2px solid var(--bg)}
|
|
2690
|
+
.tl-dot.bad{background:var(--bad)}
|
|
2691
|
+
.tl-name{font-size:11px;color:#cbd5e1;margin-top:6px;font-weight:500;text-align:center;line-height:1.2}
|
|
2692
|
+
.tl-dur{font-size:10px;color:var(--muted);margin-top:2px;text-align:center}
|
|
2693
|
+
.tl-time{font-size:9px;color:var(--muted-2);margin-top:1px;text-align:center;white-space:nowrap;letter-spacing:-.02em}
|
|
2694
|
+
.tl-repeat{position:absolute;top:-6px;right:50%;transform:translateX(20px);font-size:10px;background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;cursor:pointer;line-height:14px;min-width:14px;text-align:center}
|
|
2695
|
+
.tl-repeat:hover{background:#fbbf24}
|
|
2696
|
+
.tl-connector{position:absolute;top:23px;right:calc(-50% + 6px);width:calc(100% - 12px);height:2px;background:var(--border-2);z-index:0}
|
|
2697
|
+
.tl-legend{display:flex;gap:14px;margin-top:10px;font-size:11px;color:var(--muted);flex-wrap:wrap;align-items:center}
|
|
2698
|
+
.tl-legend span{display:inline-flex;align-items:center;gap:5px}
|
|
2699
|
+
.tl-legend i{width:10px;height:10px;border-radius:2px;display:inline-block}
|
|
2700
|
+
.tl-legend .r-demo{background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;font-size:10px}
|
|
2701
|
+
.reasons{display:flex;flex-direction:column;gap:6px;margin-top:10px}
|
|
2702
|
+
.reason-item{display:flex;align-items:center;gap:8px;background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:8px;padding:8px 10px;font-size:12px}
|
|
2703
|
+
.reason-stage{font-weight:600;color:#cbd5e1;min-width:60px}
|
|
2704
|
+
.reason-arrow{color:var(--muted-2)}
|
|
2705
|
+
.reason-tag{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500}
|
|
2706
|
+
.reason-tag.unfinished{background:rgba(245,158,11,.18);color:#fcd34d}
|
|
2707
|
+
.reason-tag.rework{background:rgba(96,165,250,.16);color:#bfdbfe}
|
|
2708
|
+
.reason-note{color:var(--muted);margin-left:auto;font-size:11px}
|
|
2709
|
+
.warn-item{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-2);font-size:12px}
|
|
2710
|
+
.warn-item:last-child{border-bottom:none}
|
|
2711
|
+
.warn-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:rgba(245,158,11,.18);color:#fcd34d;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;margin-top:1px}
|
|
2712
|
+
.warn-body{flex:1}
|
|
2713
|
+
.warn-title{color:#e5e7eb;font-weight:500}
|
|
2714
|
+
.warn-detail{color:var(--muted);margin-top:2px}
|
|
2715
|
+
.warn-status{font-size:11px;padding:2px 8px;border-radius:999px;flex-shrink:0}
|
|
2716
|
+
.warn-status.fixed{background:rgba(34,197,94,.16);color:#86efac}
|
|
2717
|
+
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
2718
|
+
@media(max-width:720px){.summary{grid-template-columns:1fr}.two-col{grid-template-columns:1fr}.grid{grid-template-columns:repeat(2,1fr)}}
|
|
2719
|
+
.quality-alert{display:flex;align-items:flex-start;gap:10px;margin-top:12px;padding:10px 12px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);border-radius:8px;font-size:12px}
|
|
2720
|
+
.quality-alert .qa-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:rgba(245,158,11,.25);color:#fcd34d;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;margin-top:1px}
|
|
2721
|
+
.quality-alert .qa-title{font-weight:600;color:#fcd34d;margin-bottom:2px}
|
|
2722
|
+
.quality-alert .qa-items div{color:#e5e7eb;line-height:1.6}
|
|
2723
|
+
.footer-note{font-size:11px;color:var(--muted-2);margin-top:16px;padding:12px 18px;background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;line-height:1.7}
|
|
2724
|
+
.footer-note strong{color:var(--muted)}
|
|
2725
|
+
.section-tag{font-size:10px;color:var(--muted-2);font-weight:400;margin-left:6px}
|
|
2726
|
+
</style>
|
|
2727
|
+
</head>
|
|
2728
|
+
<body><main>
|
|
2729
|
+
|
|
2730
|
+
<div class="card">
|
|
2731
|
+
<div class="header-row">
|
|
2732
|
+
<div>
|
|
2733
|
+
<h1>SDD 效果度量报告</h1>
|
|
2734
|
+
<div class="muted-2" style="margin-top:4px">变更:<code>${esc(report.change || 'project')}</code> · 统计范围:${esc(report.change ? `change/${report.change}` : 'project')}</div>
|
|
2735
|
+
</div>
|
|
2736
|
+
<div class="header-meta">生成时间 ${esc(genTime)}<br>项目 ${esc(projName)}</div>
|
|
2737
|
+
</div>
|
|
2738
|
+
</div>
|
|
2739
|
+
|
|
2740
|
+
<div class="card">
|
|
2741
|
+
<h2>执行摘要</h2>
|
|
2742
|
+
<div class="summary">
|
|
2743
|
+
<div class="cell">
|
|
2744
|
+
${gaugeHtml}
|
|
2745
|
+
<div class="cell-label">健康分</div>
|
|
2746
|
+
</div>
|
|
2747
|
+
<div class="cell">
|
|
2748
|
+
<div class="big-num ok">${matchedRate == null ? 'null' : (matchedRate * 100).toFixed(0)}<span style="font-size:18px">%</span></div>
|
|
2749
|
+
<div class="cell-label">阶段闭环率</div>
|
|
2750
|
+
</div>
|
|
2751
|
+
<div class="cell">
|
|
2752
|
+
<div class="big-num ${severeCount > 0 ? 'bad' : 'ok'}" style="${severeCount > 0 ? 'color:#fca5a5' : ''}">${severeCount}</div>
|
|
2753
|
+
<div class="cell-label">严重问题</div>
|
|
2754
|
+
</div>
|
|
2755
|
+
</div>
|
|
2756
|
+
${qualityAlertHtml}
|
|
2757
|
+
</div>
|
|
2758
|
+
|
|
2759
|
+
<div class="card">
|
|
2760
|
+
<h2>效率指标</h2>
|
|
2761
|
+
<div class="grid">
|
|
2762
|
+
${metricCell('E1 总时长', durHtml(eff.e1_lead_time_ms), '从需求规划到归档完成的总耗时,包含阶段间等待和你确认的时间。')}
|
|
2763
|
+
${metricCell('E2 编码时间占比', pctHtml(eff.e2_coding_time_ratio), '实际编码阶段占总有效耗时的比例。')}
|
|
2764
|
+
${metricCell('E3 规约时间占比', pctHtml(eff.e3_spec_time_ratio), '需求规划、规格、设计、任务拆解四个阶段的耗时占比。注意:包含等待你确认的时间,可能偏高。')}
|
|
2765
|
+
${metricCell('E4 一次成码率', pctHtml(eff.e4_ai_code_first_pass_rate), 'AI 一次性写对代码的比例。只统计实现类任务(不含测试骨架),且构建、测试、结果三项都通过才算成功。基于 task_update 信号判定,非代码 diff。' + (report.telemetry_warnings?.by_type?.task_update_reuse > 0 ? ' E4 受 task_update 复用影响,已扣分。' : ''))}
|
|
2766
|
+
${metricCell('有效阶段总耗时', durHtml(eff.effective_stage_duration_ms), '各阶段实际耗时之和,不含阶段间的等待和会话切换。')}
|
|
2767
|
+
${metricCell('阶段间等待', durHtml(eff.idle_time_ms), 'E1 总时长减去各阶段有效耗时之和,即阶段间等待、会话切换等未计入有效耗时的部分。')}
|
|
2768
|
+
${metricCell('流程修正耗时', durHtml(eff.rework_stage_duration_ms), '因门禁拦截、重做等流程返工花费的时间。')}
|
|
2769
|
+
</div>
|
|
2770
|
+
</div>
|
|
2771
|
+
|
|
2772
|
+
<div class="card">
|
|
2773
|
+
<h2>质量指标</h2>
|
|
2774
|
+
<div class="grid">
|
|
2775
|
+
${metricCell('Q1 规约符合度', numHtml(qual.q1_spec_conformance_score, '/1', qual.q1_human_status === 'unverified' ? '自评·待人工确认' : null), '实现与规格的符合程度,由 AI 评审断言判定。仅供参考,不作强制门禁。')}
|
|
2776
|
+
${metricCell('Q3 构建一次通过率', pctHtml(qual.q3_build_first_pass_rate), '编码阶段第一次构建就成功的比例。')}
|
|
2777
|
+
${metricCell('Q4 测试覆盖率', pctHtml(qual.q4_spec_driven_test_coverage, qual.q4_source === 'conformance-fallback' ? '近似' : null), '规格中的每条断言被测试用例覆盖的比例。本次为近似值。')}
|
|
2778
|
+
${metricCell('Q5 跨文档一致性', numHtml(qual.q5_cross_doc_consistency_score, '/1'), '需求、规格、设计、任务四份文档之间是否一致、无矛盾的评分。')}
|
|
2779
|
+
</div>
|
|
2780
|
+
</div>
|
|
2781
|
+
|
|
2782
|
+
<div class="card">
|
|
2783
|
+
<h2>过程指标 <span class="section-tag">含过程记录状态</span></h2>
|
|
2784
|
+
<div class="grid">
|
|
2785
|
+
${metricCell('P1 文档迭代次数', proc.p1_spec_iteration_count == null ? '<span class="metric-val muted-null">null<span class="unit">·无git</span></span>' : numHtml(proc.p1_spec_iteration_count, '次'), '文档被修改的次数。需要 Git 提交记录来统计,本次没有 Git 提交,所以缺失。')}
|
|
2786
|
+
${metricCell('P2 AI 代码保留率', pctHtml(proc.p2_ai_code_adoption_rate), 'AI 生成的代码最终保留了多少比例。需要 Git 对比来计算,本次没有 Git 提交,所以缺失。')}
|
|
2787
|
+
${metricCell('P4 质量门前置率', pctHtml(proc.p4_quality_gate_enforcement_rate), 'apply 前是否完成 check。本次 apply 前未执行 check 才为 0%。')}
|
|
2788
|
+
${metricCell('阶段重复执行', numHtml(reworkTotal, '次'), '同一阶段被重复执行的次数。本次编码和检查各重做了一次。')}
|
|
2789
|
+
${metricCell('被覆盖未闭环', numHtml(rs.superseded_open_stages ?? null), '开始后未正常结束、但被后续重新执行覆盖的阶段数。')}
|
|
2790
|
+
${metricCell('过程记录事件', (report.process_notes?._available ? (report.process_notes.total ?? 0) : null) == null ? '<span class="metric-val muted-null">0<span class="unit">·未采集</span></span>' : numHtml(report.process_notes.total ?? 0, '条'), '过程中发生的修复、澄清、决策、故障等事件数。本次没有记录到任何过程事件。')}
|
|
2791
|
+
</div>
|
|
2792
|
+
${reasonHtml ? `<h3 style="margin:14px 0 4px">重复执行原因分布</h3>${reasonHtml}<div class="muted-2" style="margin-top:8px">其余归类:${esc(catLine)}</div>` : ''}
|
|
2793
|
+
</div>
|
|
2794
|
+
|
|
2795
|
+
<div class="card">
|
|
2796
|
+
<h2>时间线 <span class="section-tag">横向 · 含起止时间${tlNodes.length > 0 ? ` · ${tlNodes.length} 个阶段执行` : ''}</span></h2>
|
|
2797
|
+
${tlHtml}
|
|
2798
|
+
${tlNodes.length > 0 ? `<div class="tl-legend"><span><i style="background:var(--ok)"></i>成功</span><span><i style="background:var(--bad)"></i>失败/拦截</span><span><i style="background:rgba(34,197,94,.35)"></i>短阶段</span><span><span class="r-demo">R</span>重复执行(可点击查看原因)</span><span>柱高 ∝ 耗时</span></div>` : ''}
|
|
2799
|
+
</div>
|
|
2800
|
+
|
|
2801
|
+
<div class="card">
|
|
2802
|
+
<h2>归档与产物 <span class="section-tag">变更文件 · 产物清单 · 归档结果</span></h2>
|
|
2803
|
+
<div class="two-col">
|
|
2804
|
+
<table>
|
|
2805
|
+
<tr><th>变更文件数</th><td>${cf._available ? esc(cf.files_changed ?? 'null') : '<span class="muted-2">无数据</span>'}${cf.files_diff_note ? `<div class="muted-2" style="margin-top:4px">${esc(cf.files_diff_note)}</div>` : ''}</td></tr>
|
|
2806
|
+
<tr><th>新增行数</th><td>${cf._available && cf.added_lines != null ? esc(cf.added_lines) : '<span class="muted-2">null(无 git 提交)</span>'}</td></tr>
|
|
2807
|
+
<tr><th>迁移文件数</th><td>${movedCount != null ? esc(movedCount) : '<span class="muted-2">null</span>'}</td></tr>
|
|
2808
|
+
<tr><th>任务项完成</th><td>${taskCompletion ? `${esc(taskCompletion.completed ?? 'null')} / ${esc(taskCompletion.total ?? 'null')}(含子项)` : '<span class="muted-2">null</span>'}</td></tr>
|
|
2809
|
+
</table>
|
|
2810
|
+
<table>
|
|
2811
|
+
<tr><th>归档原因</th><td>${esc(archive?.reason || 'null')}</td></tr>
|
|
2812
|
+
<tr><th>归档方式</th><td>${esc(archive?.method || 'null')}</td></tr>
|
|
2813
|
+
<tr><th>归档目录</th><td><code>${esc(archive?.archive_path || arts.archive_path || 'null')}</code></td></tr>
|
|
2814
|
+
<tr><th>最终报告</th><td><code>${esc(archive?.report_path || 'null')}</code></td></tr>
|
|
2815
|
+
</table>
|
|
2816
|
+
</div>
|
|
2817
|
+
</div>
|
|
2818
|
+
|
|
2819
|
+
<div class="card">
|
|
2820
|
+
<h2>已知风险</h2>
|
|
2821
|
+
${warnHtml}
|
|
2822
|
+
</div>
|
|
2823
|
+
|
|
2824
|
+
<div class="footer-note">
|
|
2825
|
+
<strong>说明:</strong><code>null</code> 表示该指标未采集到或暂不适用(多为无 Git 提交所致)。E4 基于 task_update 信号判定,非代码 diff。阶段时长含等待/故障耗时,未单独度量。
|
|
2826
|
+
</div>
|
|
2827
|
+
|
|
2828
|
+
</main>
|
|
2829
|
+
|
|
2830
|
+
<div class="modal-mask" id="modalMask">
|
|
2831
|
+
<div class="modal">
|
|
2832
|
+
<h4 id="modalTitle">说明</h4>
|
|
2833
|
+
<p id="modalBody"></p>
|
|
2834
|
+
<div class="close"><button id="modalClose">关闭</button></div>
|
|
2835
|
+
</div>
|
|
2836
|
+
</div>
|
|
2837
|
+
|
|
2838
|
+
<script>
|
|
2839
|
+
(function(){
|
|
2840
|
+
var mask=document.getElementById('modalMask');
|
|
2841
|
+
var titleEl=document.getElementById('modalTitle');
|
|
2842
|
+
var bodyEl=document.getElementById('modalBody');
|
|
2843
|
+
function open(title,body){titleEl.textContent=title||'说明';bodyEl.textContent=body||'';mask.classList.add('show');}
|
|
2844
|
+
function close(){mask.classList.remove('show');}
|
|
2845
|
+
document.addEventListener('click',function(e){
|
|
2846
|
+
var btn=e.target.closest('.info-btn,.tl-repeat');
|
|
2847
|
+
if(btn){e.stopPropagation();open(btn.dataset.title,btn.dataset.info);return;}
|
|
2848
|
+
if(!e.target.closest('.modal')) close();
|
|
2849
|
+
});
|
|
2850
|
+
document.getElementById('modalClose').addEventListener('click',close);
|
|
2851
|
+
document.addEventListener('keydown',function(e){if(e.key==='Escape') close();});
|
|
2852
|
+
})();
|
|
2853
|
+
</script>
|
|
2854
|
+
</body></html>`;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// ── U1 报告维度提取辅助:从 events/metrics/doctor/archive 提炼 5 维度,数据缺失诚实标注 _available: false ──
|
|
2858
|
+
function extractChangedFiles(events, projectRoot) {
|
|
2859
|
+
const adoptionEvents = events.filter(e => e.type === 'ai_adoption_review' || Boolean(e.details?.ai_adoption) || Boolean(e.details?.ai_code_adoption));
|
|
2860
|
+
const latest = latestByTimestamp(adoptionEvents);
|
|
2861
|
+
const review = latest ? getAiAdoptionReview(latest) : null;
|
|
2862
|
+
const aiDiff = review?.ai_diff || null;
|
|
2863
|
+
const hasFiles = Array.isArray(aiDiff?.files) && aiDiff.files.length > 0;
|
|
2864
|
+
// P0-4: ai_diff 无效时,从 assertions[].files 聚合兜底(补救存量 ai_adoption_review 无 ai_diff 的事件)
|
|
2865
|
+
if (!aiDiff || (aiDiff.files_changed == null && aiDiff.added_lines == null && !hasFiles)) {
|
|
2866
|
+
if (latest?.details) {
|
|
2867
|
+
const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
|
|
2868
|
+
const rawAssertions = rawReview.assertions;
|
|
2869
|
+
if (Array.isArray(rawAssertions)) {
|
|
2870
|
+
const filesFromAssertions = new Set();
|
|
2871
|
+
for (const a of rawAssertions) {
|
|
2872
|
+
if (Array.isArray(a.files)) {
|
|
2873
|
+
for (const f of a.files) {
|
|
2874
|
+
if (typeof f === 'string' && f.trim()) filesFromAssertions.add(f.trim());
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
if (filesFromAssertions.size > 0) {
|
|
2879
|
+
return { _available: true, files_changed: filesFromAssertions.size, added_lines: null, files: [...filesFromAssertions] };
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
return { _available: false, files_changed: null, added_lines: null, files: null };
|
|
2884
|
+
}
|
|
2885
|
+
let addedLines = Number.isFinite(aiDiff.added_lines) ? aiDiff.added_lines : null;
|
|
2886
|
+
const files = hasFiles ? aiDiff.files.slice() : null;
|
|
2887
|
+
// T3.3: ai_git_sha=null(无 git)时,用 fs 读 files 行数作 added_lines fallback(DC-104 无 git/路径缺失不崩溃)
|
|
2888
|
+
if (addedLines == null && review && review.ai_git_sha == null && hasFiles && projectRoot) {
|
|
2889
|
+
let sum = 0;
|
|
2890
|
+
let counted = 0;
|
|
2891
|
+
const projectRootResolved = path.resolve(projectRoot);
|
|
2892
|
+
for (const rel of aiDiff.files) {
|
|
2893
|
+
try {
|
|
2894
|
+
// Y1+Y6 修复:校验路径不穿越 projectRoot + 用 split 替代 match 减少正则数组开销
|
|
2895
|
+
const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(projectRoot, rel);
|
|
2896
|
+
if (!abs.startsWith(projectRootResolved + path.sep) && abs !== projectRootResolved) continue;
|
|
2897
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
2898
|
+
const newlines = content.split('\n').length - 1;
|
|
2899
|
+
sum += newlines + (content.length > 0 && !content.endsWith('\n') ? 1 : 0);
|
|
2900
|
+
counted += 1;
|
|
2901
|
+
} catch {
|
|
2902
|
+
// 文件不存在/不可读,跳过
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
if (counted > 0) addedLines = sum;
|
|
2906
|
+
}
|
|
2907
|
+
// A6(v3): task_update 汇总文件数优先于 ai_diff 自报值(ai_diff 是 agent 一次性快照,
|
|
2908
|
+
// task_update 累加去重更准);两者不一致时 ai_diff 作辅助并标注差异。
|
|
2909
|
+
// 兼容 task_update.details.files / details.test_results.files / details.files_changed(数组形态)
|
|
2910
|
+
const taskUpdateFiles = new Set();
|
|
2911
|
+
for (const e of events) {
|
|
2912
|
+
if (e?.type !== 'task_update') continue;
|
|
2913
|
+
const filesField = e.details?.files
|
|
2914
|
+
|| e.details?.test_results?.files
|
|
2915
|
+
|| (Array.isArray(e.details?.files_changed) ? e.details.files_changed : null);
|
|
2916
|
+
if (Array.isArray(filesField)) {
|
|
2917
|
+
for (const f of filesField) {
|
|
2918
|
+
if (typeof f === 'string' && f.trim()) taskUpdateFiles.add(f.trim());
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
const taskUpdateFileCount = taskUpdateFiles.size;
|
|
2923
|
+
const aiDiffFileCount = Number.isFinite(aiDiff.files_changed) ? aiDiff.files_changed : (files ? files.length : null);
|
|
2924
|
+
let filesChanged;
|
|
2925
|
+
let aiDiffFilesChanged = null;
|
|
2926
|
+
let filesDiffNote = null;
|
|
2927
|
+
if (taskUpdateFileCount > 0) {
|
|
2928
|
+
filesChanged = taskUpdateFileCount;
|
|
2929
|
+
if (aiDiffFileCount != null && aiDiffFileCount !== taskUpdateFileCount) {
|
|
2930
|
+
aiDiffFilesChanged = aiDiffFileCount;
|
|
2931
|
+
filesDiffNote = `ai_diff 自报 ${aiDiffFileCount} 与 task_update 汇总 ${taskUpdateFileCount} 不一致,已采用 task_update 汇总值`;
|
|
2932
|
+
}
|
|
2933
|
+
} else {
|
|
2934
|
+
filesChanged = aiDiffFileCount;
|
|
2935
|
+
}
|
|
2936
|
+
return {
|
|
2937
|
+
_available: true,
|
|
2938
|
+
files_changed: filesChanged,
|
|
2939
|
+
added_lines: addedLines,
|
|
2940
|
+
files,
|
|
2941
|
+
ai_diff_files_changed: aiDiffFilesChanged,
|
|
2942
|
+
files_diff_note: filesDiffNote,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function extractTimeline(events) {
|
|
2947
|
+
const nodes = events
|
|
2948
|
+
.filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end' || e.type === 'build_result' || e.type === 'task_update'))
|
|
2949
|
+
.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
2950
|
+
if (nodes.length === 0) return { _available: false, nodes: null };
|
|
2951
|
+
return {
|
|
2952
|
+
_available: true,
|
|
2953
|
+
nodes: nodes.map(e => ({
|
|
2954
|
+
timestamp: e.timestamp || null,
|
|
2955
|
+
type: e.type,
|
|
2956
|
+
command: e.command || e.stage || null,
|
|
2957
|
+
result: e.result || null,
|
|
2958
|
+
})),
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// ── T2.3: 阶段级时间线节点(横向时间线数据源)── 配对 stage_start/stage_end 为一次阶段执行,
|
|
2963
|
+
// 折叠其间的 task_update 数量,标记 round(>1 即重复执行)。按 event_id 配对(同 summarizeStageExecutions)。
|
|
2964
|
+
function extractStageTimeline(events) {
|
|
2965
|
+
const stageEvents = events.filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end'));
|
|
2966
|
+
const starts = stageEvents
|
|
2967
|
+
.filter(e => e.type === 'stage_start')
|
|
2968
|
+
.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
2969
|
+
const ends = stageEvents.filter(e => e.type === 'stage_end');
|
|
2970
|
+
const endsById = new Map();
|
|
2971
|
+
for (const end of ends) {
|
|
2972
|
+
if (!endsById.has(end.event_id)) endsById.set(end.event_id, []);
|
|
2973
|
+
endsById.get(end.event_id).push(end);
|
|
2974
|
+
}
|
|
2975
|
+
const roundCount = {};
|
|
2976
|
+
// Y7 修复:预排序 task_updates 供 starts.map 扫描区间(降 O(starts×events) 为 O(starts+events))
|
|
2977
|
+
const taskUpdates = events.filter(e => e.type === 'task_update').sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
2978
|
+
const nodes = starts.map(start => {
|
|
2979
|
+
const end = latestByTimestamp(endsById.get(start.event_id) || []);
|
|
2980
|
+
const cmd = start.command || start.stage || 'unknown';
|
|
2981
|
+
roundCount[cmd] = (roundCount[cmd] || 0) + 1;
|
|
2982
|
+
const round = roundCount[cmd];
|
|
2983
|
+
const startMs = timestampMs(start);
|
|
2984
|
+
const endMs = end ? timestampMs(end) : null;
|
|
2985
|
+
// Y7 修复:用预排序 taskUpdates 单次扫描区间 O(n),替代全量 filter O(starts×events)
|
|
2986
|
+
let taskCount = 0;
|
|
2987
|
+
for (const t of taskUpdates) {
|
|
2988
|
+
const tMs = timestampMs(t);
|
|
2989
|
+
if (tMs < startMs) continue;
|
|
2990
|
+
if (endMs != null && tMs > endMs) break;
|
|
2991
|
+
taskCount++;
|
|
2992
|
+
}
|
|
2993
|
+
return {
|
|
2994
|
+
stage: cmd,
|
|
2995
|
+
round,
|
|
2996
|
+
start_ts: start.timestamp || null,
|
|
2997
|
+
end_ts: end?.timestamp || null,
|
|
2998
|
+
duration_ms: end && Number.isFinite(end.duration_ms) ? end.duration_ms : null,
|
|
2999
|
+
result: end?.result || null,
|
|
3000
|
+
task_count: taskCount,
|
|
3001
|
+
};
|
|
3002
|
+
});
|
|
3003
|
+
if (nodes.length === 0) return { _available: false, nodes: null };
|
|
3004
|
+
return { _available: true, nodes };
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function extractStageStatus(metrics) {
|
|
3008
|
+
const byStage = metrics?.process?.rework_summary?.by_stage || [];
|
|
3009
|
+
if (byStage.length === 0) return { _available: false, stages: null };
|
|
3010
|
+
return {
|
|
3011
|
+
_available: true,
|
|
3012
|
+
stages: byStage.map(b => ({
|
|
3013
|
+
command: b.command,
|
|
3014
|
+
capability: b.capability,
|
|
3015
|
+
total_attempts: b.total_attempts,
|
|
3016
|
+
successful_attempts: b.successful_attempts,
|
|
3017
|
+
rework_attempts: b.rework_attempts,
|
|
3018
|
+
canonical_event_id: b.canonical_event_id || null,
|
|
3019
|
+
})),
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
function extractKnownRisks(events, doctor, archiveResult) {
|
|
3024
|
+
const severeIssues = doctor?.severe_issues || [];
|
|
3025
|
+
const warnings = [];
|
|
3026
|
+
// Q7: 从 archive task_completion 判断 warn 是否已修复(incomplete=0 → resolved)
|
|
3027
|
+
const taskCompletion = archiveResult?.task_completion;
|
|
3028
|
+
const allResolved = taskCompletion && Number.isFinite(taskCompletion.incomplete) && taskCompletion.incomplete === 0;
|
|
3029
|
+
for (const e of events) {
|
|
3030
|
+
const cr = e.details?.check_results;
|
|
3031
|
+
if (cr && Number.isFinite(cr.warnings) && cr.warnings > 0) {
|
|
3032
|
+
// T2.1: 携带 warning_items 明细(若有);旧事件无该字段时降级为仅数量(DC-101/UT-202)
|
|
3033
|
+
const entry = { command: e.command || e.stage || null, warnings: cr.warnings };
|
|
3034
|
+
if (Array.isArray(cr.warning_items) && cr.warning_items.length > 0) {
|
|
3035
|
+
entry.items = cr.warning_items;
|
|
3036
|
+
}
|
|
3037
|
+
// Q7: 归档后 incomplete=0 → 标已修复
|
|
3038
|
+
if (allResolved) entry.resolved = true;
|
|
3039
|
+
warnings.push(entry);
|
|
3040
|
+
}
|
|
3041
|
+
const conf = getConformanceReview(e);
|
|
3042
|
+
if (conf && Array.isArray(conf.assertions)) {
|
|
3043
|
+
// normalizeConformanceStatus 归一化为 matched/partial/missed;missed 表示断言未匹配(等价失败)
|
|
3044
|
+
const failed = conf.assertions.filter(a => a.status === 'missed');
|
|
3045
|
+
if (failed.length > 0) {
|
|
3046
|
+
const confEntry = { command: e.command || 'conformance', failed_assertions: failed.length };
|
|
3047
|
+
if (allResolved) confEntry.resolved = true;
|
|
3048
|
+
warnings.push(confEntry);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
if (severeIssues.length === 0 && warnings.length === 0) {
|
|
3053
|
+
return { _available: false, severe_issues: null, warnings: null };
|
|
3054
|
+
}
|
|
3055
|
+
return { _available: true, severe_issues: severeIssues, warnings };
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
function extractArtifacts(archiveEvent) {
|
|
3059
|
+
const ar = archiveEvent?.details?.archive_result;
|
|
3060
|
+
if (!ar) return { _available: false, archive_path: null, manifest_path: null, moved_files: null, copied_specs: null };
|
|
3061
|
+
const movedFiles = Array.isArray(ar.moved_files) ? ar.moved_files : (Array.isArray(ar.movedFiles) ? ar.movedFiles : null);
|
|
3062
|
+
// P0-1: copied_specs 是归档时实际迁移的 spec 文件清单(archive-manifest 字段)
|
|
3063
|
+
const copiedSpecs = Array.isArray(ar.copied_specs) ? ar.copied_specs : (Array.isArray(ar.copiedSpecs) ? ar.copiedSpecs : null);
|
|
3064
|
+
return {
|
|
3065
|
+
_available: true,
|
|
3066
|
+
archive_path: ar.archive_path || null,
|
|
3067
|
+
manifest_path: ar.manifest_path || null,
|
|
3068
|
+
moved_files: movedFiles,
|
|
3069
|
+
copied_specs: copiedSpecs,
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// ── U2/U3: 过程事件(process_note)提取 —— 阶段内修复/澄清/决策/API 故障/换模型等,可统计可追溯 ──
|
|
3074
|
+
function extractProcessNotes(events) {
|
|
3075
|
+
const notes = events.filter(e => !e.orphan && e.type === 'process_note');
|
|
3076
|
+
if (notes.length === 0) return { _available: false, total: 0, by_kind: null, by_stage: null, notes: null };
|
|
3077
|
+
const byKind = {};
|
|
3078
|
+
const byStage = {};
|
|
3079
|
+
for (const n of notes) {
|
|
3080
|
+
const kind = n.details?.kind || 'other';
|
|
3081
|
+
byKind[kind] = (byKind[kind] || 0) + 1;
|
|
3082
|
+
const stage = n.command || n.stage || 'unknown';
|
|
3083
|
+
byStage[stage] = (byStage[stage] || 0) + 1;
|
|
3084
|
+
}
|
|
3085
|
+
return {
|
|
3086
|
+
_available: true,
|
|
3087
|
+
total: notes.length,
|
|
3088
|
+
by_kind: byKind,
|
|
3089
|
+
by_stage: byStage,
|
|
3090
|
+
notes: notes.map(n => ({
|
|
3091
|
+
timestamp: n.timestamp || null,
|
|
3092
|
+
stage: n.command || n.stage || null,
|
|
3093
|
+
kind: n.details?.kind || 'other',
|
|
3094
|
+
summary: n.summary || n.details?.summary || '',
|
|
3095
|
+
target: n.details?.target || null,
|
|
3096
|
+
round: Number.isFinite(n.details?.round) ? n.details.round : null,
|
|
3097
|
+
})),
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// ── T3.2: telemetry 警告提取 —— gate 记录的 task_update_reuse / process_note_missing / apply_test_missing
|
|
3102
|
+
// 供执行摘要「过程质量信号警示条」判定 task_update 复用。事件结构由 sdd-apply-test-gate.cjs recordWarning 写入。
|
|
3103
|
+
function extractTelemetryWarnings(events) {
|
|
3104
|
+
const warnings = events.filter(e => !e.orphan && e.type === 'telemetry_warning');
|
|
3105
|
+
if (warnings.length === 0) return { _available: false, total: 0, by_type: {}, items: [] };
|
|
3106
|
+
const byType = {};
|
|
3107
|
+
const items = [];
|
|
3108
|
+
for (const w of warnings) {
|
|
3109
|
+
const t = (w.details && (w.details.warning || w.details.type || w.details.warning_type)) || 'unknown';
|
|
3110
|
+
byType[t] = (byType[t] || 0) + 1;
|
|
3111
|
+
items.push({ type: t, timestamp: w.timestamp || null, message: w.summary || (w.details && w.details.message) || '' });
|
|
3112
|
+
}
|
|
3113
|
+
return { _available: true, total: warnings.length, by_type: byType, items };
|
|
3114
|
+
}
|
|
3115
|
+
|
|
1915
3116
|
function buildReport(projectRoot, events, options = {}) {
|
|
1916
3117
|
const archiveEvent = latestByTimestamp(events.filter(e => {
|
|
1917
3118
|
return e.command === 'archive' && e.type === 'stage_end' && !e.orphan;
|
|
1918
3119
|
}));
|
|
3120
|
+
const metrics = computePdfMvpMetrics(events, {
|
|
3121
|
+
level: options.level || (options.change ? 'change' : 'project'),
|
|
3122
|
+
change: options.change,
|
|
3123
|
+
capability: options.capability,
|
|
3124
|
+
projectRoot,
|
|
3125
|
+
});
|
|
3126
|
+
const doctor = computeDoctorReport(events, { change: options.change, projectRoot });
|
|
1919
3127
|
return {
|
|
1920
3128
|
generated_at: nowISO(),
|
|
1921
3129
|
project_root: projectRoot,
|
|
1922
3130
|
change: options.change || null,
|
|
1923
3131
|
level: options.level || (options.change ? 'change' : 'project'),
|
|
1924
|
-
metrics
|
|
1925
|
-
|
|
1926
|
-
change: options.change,
|
|
1927
|
-
capability: options.capability,
|
|
1928
|
-
projectRoot,
|
|
1929
|
-
}),
|
|
1930
|
-
doctor: computeDoctorReport(events, { change: options.change }),
|
|
3132
|
+
metrics,
|
|
3133
|
+
doctor,
|
|
1931
3134
|
archive_result: archiveEvent?.details?.archive_result || null,
|
|
3135
|
+
changed_files: extractChangedFiles(events, projectRoot),
|
|
3136
|
+
timeline: extractTimeline(events),
|
|
3137
|
+
stage_timeline: extractStageTimeline(events),
|
|
3138
|
+
stage_status: extractStageStatus(metrics),
|
|
3139
|
+
known_risks: extractKnownRisks(events, doctor, archiveEvent?.details?.archive_result),
|
|
3140
|
+
artifacts: extractArtifacts(archiveEvent),
|
|
3141
|
+
process_notes: extractProcessNotes(events),
|
|
3142
|
+
telemetry_warnings: extractTelemetryWarnings(events),
|
|
1932
3143
|
};
|
|
1933
3144
|
}
|
|
1934
3145
|
|
|
@@ -1948,6 +3159,7 @@ function computeDoctorReport(events, options = {}) {
|
|
|
1948
3159
|
? events.filter(e => e.change === options.change)
|
|
1949
3160
|
: events;
|
|
1950
3161
|
const executionSummary = summarizeStageExecutions(scopedEvents);
|
|
3162
|
+
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
|
|
1951
3163
|
const stageEvents = scopedEvents.filter(e => e.type === 'stage_start' || e.type === 'stage_end');
|
|
1952
3164
|
const starts = stageEvents.filter(e => e.type === 'stage_start');
|
|
1953
3165
|
const ends = stageEvents.filter(e => e.type === 'stage_end');
|
|
@@ -1960,13 +3172,13 @@ function computeDoctorReport(events, options = {}) {
|
|
|
1960
3172
|
|
|
1961
3173
|
const matchedStarts = starts.filter(e => endsById.has(e.event_id));
|
|
1962
3174
|
const supersededOpenIds = new Set(executionSummary.reworkAttempts
|
|
1963
|
-
.filter(attempt => attempt.rework_reason === '
|
|
3175
|
+
.filter(attempt => attempt.rework_reason === 'incomplete')
|
|
1964
3176
|
.map(attempt => attempt.start.event_id));
|
|
1965
3177
|
const supersededOpenStages = starts.filter(e => supersededOpenIds.has(e.event_id));
|
|
1966
3178
|
const openStages = starts.filter(e => !endsById.has(e.event_id) && !supersededOpenIds.has(e.event_id));
|
|
1967
3179
|
const orphanEnds = ends.filter(e => e.orphan || !startsById.has(e.event_id));
|
|
1968
3180
|
const effectiveStartCount = starts.length - supersededOpenStages.length;
|
|
1969
|
-
const matchedStageRate = effectiveStartCount === 0 ?
|
|
3181
|
+
const matchedStageRate = effectiveStartCount === 0 ? null : matchedStarts.length / effectiveStartCount;
|
|
1970
3182
|
|
|
1971
3183
|
const unknownCommandEvents = stageEvents.filter(e => !e.command || e.command === 'unknown');
|
|
1972
3184
|
const unknownAgentEvents = stageEvents.filter(e => !e.agent_type || e.agent_type === 'unknown');
|
|
@@ -2011,7 +3223,7 @@ function computeDoctorReport(events, options = {}) {
|
|
|
2011
3223
|
|
|
2012
3224
|
return {
|
|
2013
3225
|
telemetry_health_score: telemetryHealthScore,
|
|
2014
|
-
matched_stage_rate: Math.round(matchedStageRate * 100) / 100,
|
|
3226
|
+
matched_stage_rate: matchedStageRate == null ? null : Math.round(matchedStageRate * 100) / 100,
|
|
2015
3227
|
total_events: scopedEvents.length,
|
|
2016
3228
|
stage_events: stageEvents.length,
|
|
2017
3229
|
start_events: starts.length,
|
|
@@ -2030,7 +3242,7 @@ function computeDoctorReport(events, options = {}) {
|
|
|
2030
3242
|
rework_attempts: executionSummary.summary.total_rework_attempts,
|
|
2031
3243
|
completed_rework_attempts: executionSummary.summary.completed_rework_attempts,
|
|
2032
3244
|
rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
|
|
2033
|
-
rework_summary: compactReworkSummary(executionSummary.summary),
|
|
3245
|
+
rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
|
|
2034
3246
|
severe_issues: severeIssues,
|
|
2035
3247
|
warnings,
|
|
2036
3248
|
};
|
|
@@ -2090,6 +3302,13 @@ function cmdStart(args) {
|
|
|
2090
3302
|
fail(`context JSON 解析失败: ${err.message}`);
|
|
2091
3303
|
}
|
|
2092
3304
|
|
|
3305
|
+
// apply 时记录 worktree_used/worktree_dir(从 SKILL 传入 --worktree-used --worktree-dir)
|
|
3306
|
+
// CLI parseArgs 把 --worktree-used=true 解析为字符串 "true",需同时接受布尔与字符串
|
|
3307
|
+
const isTrue = (v) => v === true || v === 'true';
|
|
3308
|
+
const worktreeUsed = isTrue(args['worktree-used']) || isTrue(args['worktree_used']);
|
|
3309
|
+
const worktreeDir = args['worktree-dir'] || args['worktree_dir'] || null;
|
|
3310
|
+
const worktreeDetails = worktreeUsed ? { worktree_used: true, worktree_dir: worktreeDir } : undefined;
|
|
3311
|
+
|
|
2093
3312
|
const event = cleanOptionalFields({
|
|
2094
3313
|
schema_version: SCHEMA_VERSION,
|
|
2095
3314
|
event_id: eventId,
|
|
@@ -2106,6 +3325,7 @@ function cmdStart(args) {
|
|
|
2106
3325
|
git_sha: gitSha,
|
|
2107
3326
|
timestamp,
|
|
2108
3327
|
context,
|
|
3328
|
+
details: worktreeDetails,
|
|
2109
3329
|
});
|
|
2110
3330
|
|
|
2111
3331
|
appendEvent(dataDir, event.change, event);
|
|
@@ -2125,16 +3345,22 @@ function cmdStart(args) {
|
|
|
2125
3345
|
*/
|
|
2126
3346
|
function cmdEnd(args, options = {}) {
|
|
2127
3347
|
let eventId = args['event-id'] || args.event_id;
|
|
2128
|
-
|
|
2129
|
-
|
|
3348
|
+
let result = args.result;
|
|
3349
|
+
let summary = args.summary || '';
|
|
2130
3350
|
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2131
|
-
let reportOutput = args['report-output'] || args.report_output ||
|
|
3351
|
+
let reportOutput = args['report-output'] || args.report_output || '';
|
|
2132
3352
|
|
|
2133
3353
|
if (!result) {
|
|
2134
3354
|
console.error('错误: 缺少 --result 参数(success/failure/partial)');
|
|
2135
3355
|
process.exit(1);
|
|
2136
3356
|
}
|
|
2137
3357
|
|
|
3358
|
+
const reason = args.reason;
|
|
3359
|
+
if (reason !== undefined && reason !== null && !REWORK_REASON_CATEGORIES.includes(reason)) {
|
|
3360
|
+
console.error(`错误: --reason 非法枚举(${reason}),合法值:${REWORK_REASON_CATEGORIES.join('/')}`);
|
|
3361
|
+
process.exit(1);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
2138
3364
|
const dataDir = getDataDir(projectRoot);
|
|
2139
3365
|
const activeCriteria = {
|
|
2140
3366
|
session_id: args['session-id'] || args.session_id,
|
|
@@ -2148,6 +3374,42 @@ function cmdEnd(args, options = {}) {
|
|
|
2148
3374
|
if (!eventId && startEvent) {
|
|
2149
3375
|
eventId = startEvent.event_id;
|
|
2150
3376
|
}
|
|
3377
|
+
// stage_end 幂等性保护:同一 event_id 已存在 stage_end 时,结果相同则跳过,不同则更新(archive 命令只要存在即跳过)
|
|
3378
|
+
const command = startEvent?.command || args.command || 'unknown';
|
|
3379
|
+
const change = startEvent?.change || args.change || args['change-name'] || 'general';
|
|
3380
|
+
const existingEnds = readAllEventEndsById(dataDir, eventId, change);
|
|
3381
|
+
if (existingEnds.length > 0) {
|
|
3382
|
+
const latestExisting = existingEnds.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
|
|
3383
|
+
// archive 命令已存在 stage_end 时直接跳过,避免 result 突变后重复写入
|
|
3384
|
+
if (command === 'archive') {
|
|
3385
|
+
const output = {
|
|
3386
|
+
event_id: eventId,
|
|
3387
|
+
duration_ms: latestExisting.duration_ms,
|
|
3388
|
+
recorded_at: latestExisting.timestamp,
|
|
3389
|
+
report_output: latestExisting.details?.archive_result?.report_path || '',
|
|
3390
|
+
message: `SDD ${latestExisting.command || command} 阶段结束(archive,已存在相同 event_id 的 stage_end,幂等跳过)`,
|
|
3391
|
+
};
|
|
3392
|
+
if (!options.silent) {
|
|
3393
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3394
|
+
}
|
|
3395
|
+
return output;
|
|
3396
|
+
}
|
|
3397
|
+
if (latestExisting.result === result) {
|
|
3398
|
+
// 结果相同:幂等跳过,返回统一 shape
|
|
3399
|
+
const output = {
|
|
3400
|
+
event_id: eventId,
|
|
3401
|
+
duration_ms: latestExisting.duration_ms,
|
|
3402
|
+
recorded_at: latestExisting.timestamp,
|
|
3403
|
+
report_output: latestExisting.details?.archive_result?.report_path || '',
|
|
3404
|
+
message: `SDD ${latestExisting.command || command} 阶段结束(${result},已存在相同 event_id 的 stage_end,幂等跳过)`,
|
|
3405
|
+
};
|
|
3406
|
+
if (!options.silent) {
|
|
3407
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3408
|
+
}
|
|
3409
|
+
return output;
|
|
3410
|
+
}
|
|
3411
|
+
// 结果不同:允许继续写入新的 stage_end(覆盖/更新)
|
|
3412
|
+
}
|
|
2151
3413
|
const orphan = !startEvent;
|
|
2152
3414
|
if (!eventId) {
|
|
2153
3415
|
eventId = generateEventId();
|
|
@@ -2164,13 +3426,18 @@ function cmdEnd(args, options = {}) {
|
|
|
2164
3426
|
? new Date(timestamp).getTime() - new Date(startEvent.timestamp).getTime()
|
|
2165
3427
|
: null;
|
|
2166
3428
|
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
3429
|
+
// command 和 change 已在幂等性检查前声明
|
|
3430
|
+
if (!reportOutput && args['generate-report']) {
|
|
3431
|
+
// archive success 时归档已把 change 移到 archive 目录,此时若默认活跃路径会让
|
|
3432
|
+
// ensureArchiveSuccessArtifacts 走显式分支 → 报告落活跃路径 + ensureDir 重建空 active。
|
|
3433
|
+
// 故 archive success 不设活跃默认,由 repaired.reportPath 兜底到 archive 目录。
|
|
3434
|
+
if (!(command === 'archive' && result === 'success')) {
|
|
3435
|
+
reportOutput = path.join('openspec', 'changes', change, 'reports', `${safeChangeName(change)}-report.md`);
|
|
3436
|
+
}
|
|
2171
3437
|
}
|
|
3438
|
+
let repaired = null;
|
|
2172
3439
|
if (command === 'archive' && result === 'success') {
|
|
2173
|
-
|
|
3440
|
+
repaired = ensureArchiveSuccessArtifacts(projectRoot, change, details, {
|
|
2174
3441
|
reason: details.archive_result?.reason || '',
|
|
2175
3442
|
reportOutput,
|
|
2176
3443
|
});
|
|
@@ -2184,6 +3451,22 @@ function cmdEnd(args, options = {}) {
|
|
|
2184
3451
|
};
|
|
2185
3452
|
}
|
|
2186
3453
|
}
|
|
3454
|
+
// archive 归档完成后,扫描 task_completion,has_incomplete 降为 partial(仍归档,仅 result 标记提示未勾验收)
|
|
3455
|
+
if (command === 'archive' && result === 'success') {
|
|
3456
|
+
try {
|
|
3457
|
+
const archiveDirRel = repaired?.archive_path || details.archive_result?.archive_path || null;
|
|
3458
|
+
const archiveDirAbs = archiveDirRel ? (path.isAbsolute(archiveDirRel) ? archiveDirRel : path.join(projectRoot, archiveDirRel)) : null;
|
|
3459
|
+
const taskCompletion = archiveDirAbs
|
|
3460
|
+
? scanTaskCompletionForArchiveDir(projectRoot, change, archiveDirAbs)
|
|
3461
|
+
: scanTaskCompletion(projectRoot, change);
|
|
3462
|
+
if (taskCompletion.has_incomplete) {
|
|
3463
|
+
result = 'partial';
|
|
3464
|
+
summary = `${summary}(${taskCompletion.incomplete} 项验收标准未勾选)`.trim();
|
|
3465
|
+
}
|
|
3466
|
+
} catch (err) {
|
|
3467
|
+
try { console.error(`[telemetry] archive 降级检查失败(不阻塞): ${err.message}`); } catch {}
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
2187
3470
|
const event = cleanOptionalFields({
|
|
2188
3471
|
schema_version: SCHEMA_VERSION,
|
|
2189
3472
|
event_id: eventId,
|
|
@@ -2203,6 +3486,7 @@ function cmdEnd(args, options = {}) {
|
|
|
2203
3486
|
result,
|
|
2204
3487
|
summary,
|
|
2205
3488
|
details,
|
|
3489
|
+
rework_reason: reason || undefined,
|
|
2206
3490
|
orphan: orphan ? true : undefined,
|
|
2207
3491
|
});
|
|
2208
3492
|
|
|
@@ -2211,23 +3495,69 @@ function cmdEnd(args, options = {}) {
|
|
|
2211
3495
|
clearActiveStage(projectRoot, startEvent);
|
|
2212
3496
|
}
|
|
2213
3497
|
|
|
3498
|
+
// archive 时追加"变更总结"段到 execution-log.md(T1.3:移到 appendEvent 之后,确保总结出现在 archive_end 行之后)
|
|
3499
|
+
if (command === 'archive') {
|
|
3500
|
+
try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
|
|
3501
|
+
try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
|
|
2214
3505
|
let resolvedReportOutput = '';
|
|
2215
3506
|
if (reportOutput) {
|
|
3507
|
+
resolvedReportOutput = path.isAbsolute(reportOutput) ? reportOutput : path.resolve(projectRoot, reportOutput);
|
|
3508
|
+
} else if (repaired && repaired.reportPath) {
|
|
3509
|
+
resolvedReportOutput = repaired.reportPath;
|
|
3510
|
+
}
|
|
3511
|
+
if (resolvedReportOutput) {
|
|
2216
3512
|
const events = readEvents(dataDir, event.change);
|
|
2217
3513
|
const report = buildReport(projectRoot, events, {
|
|
2218
3514
|
level: args['report-level'] || 'change',
|
|
2219
3515
|
change: event.change,
|
|
2220
3516
|
capability: args.capability || args['capability-name'] || startEvent?.capability,
|
|
2221
3517
|
});
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
3518
|
+
const reportFormat = args['report-format'] || 'markdown';
|
|
3519
|
+
const isHtmlPrimary = reportFormat === 'html';
|
|
3520
|
+
const rendered = reportFormat === 'json'
|
|
3521
|
+
? JSON.stringify(report, null, 2)
|
|
3522
|
+
: (isHtmlPrimary ? renderExecutiveReportHtml(report) : renderExecutiveReportMarkdown(report));
|
|
3523
|
+
ensureDir(path.dirname(resolvedReportOutput));
|
|
3524
|
+
fs.writeFileSync(resolvedReportOutput, rendered + '\n', 'utf8');
|
|
3525
|
+
|
|
3526
|
+
// 默认双输出:md + html,避免只生成单一格式;但仅对 .md/.html 主路径派生副本,防止非标准路径被静默覆盖
|
|
3527
|
+
if (reportFormat !== 'json') {
|
|
3528
|
+
try {
|
|
3529
|
+
const primaryExt = path.extname(resolvedReportOutput).toLowerCase();
|
|
3530
|
+
const isMdPrimary = primaryExt === '.md';
|
|
3531
|
+
const isHtmlExt = primaryExt === '.html';
|
|
3532
|
+
if (isMdPrimary || isHtmlExt) {
|
|
3533
|
+
const companionSuffix = isHtmlExt ? '.md' : '.html';
|
|
3534
|
+
const companionRender = isHtmlExt ? renderExecutiveReportMarkdown(report) : renderExecutiveReportHtml(report);
|
|
3535
|
+
const htmlPath = resolvedReportOutput.replace(/\.[^.]+$/, companionSuffix);
|
|
3536
|
+
if (htmlPath !== resolvedReportOutput) {
|
|
3537
|
+
fs.writeFileSync(htmlPath, companionRender + '\n', 'utf8');
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
} catch (err) {
|
|
3541
|
+
try { console.error(`[telemetry] ${isHtmlPrimary ? 'md' : 'html'} 报告副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
// A1(v3): appendEvent 已把 archive stage_end 写入活跃 jsonl,但上方 ensureArchiveSuccessArtifacts
|
|
3547
|
+
// 在 appendEvent 之前拷贝 events → archive/evidence/jsonl 缺末条 stage_end。此处重拷覆盖,幂等。
|
|
3548
|
+
// 触发条件:archive 且归档产物已生成(repaired 非空,即原始 result=success,不含 failure/纯 partial)。
|
|
3549
|
+
if (command === 'archive' && repaired && details.archive_result?.archive_path) {
|
|
3550
|
+
try {
|
|
3551
|
+
const archiveDirAbs = path.isAbsolute(details.archive_result.archive_path)
|
|
3552
|
+
? details.archive_result.archive_path
|
|
3553
|
+
: path.resolve(projectRoot, details.archive_result.archive_path);
|
|
3554
|
+
const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(change));
|
|
3555
|
+
if (fs.existsSync(eventsSrcDir) && fs.existsSync(archiveDirAbs)) {
|
|
3556
|
+
copyDirSync(eventsSrcDir, path.join(archiveDirAbs, 'evidence', 'events'));
|
|
3557
|
+
}
|
|
3558
|
+
} catch (err) {
|
|
3559
|
+
try { console.error(`[telemetry] archive events 重拷失败(不阻塞): ${err.message}`); } catch {}
|
|
3560
|
+
}
|
|
2231
3561
|
}
|
|
2232
3562
|
|
|
2233
3563
|
const output = {
|
|
@@ -2265,6 +3595,8 @@ function cmdRecord(args) {
|
|
|
2265
3595
|
'baseline_record',
|
|
2266
3596
|
'telemetry_warning',
|
|
2267
3597
|
'worktree_finish',
|
|
3598
|
+
'process_note',
|
|
3599
|
+
'doc_update',
|
|
2268
3600
|
];
|
|
2269
3601
|
|
|
2270
3602
|
if (!type) {
|
|
@@ -2278,12 +3610,75 @@ function cmdRecord(args) {
|
|
|
2278
3610
|
}
|
|
2279
3611
|
|
|
2280
3612
|
let details;
|
|
3613
|
+
let detailsFilePath = null;
|
|
2281
3614
|
try {
|
|
2282
3615
|
details = parseJsonOption(args, 'details-json', 'details-file', projectRoot, {});
|
|
3616
|
+
detailsFilePath = args['details-file'] || args.details_file || null;
|
|
3617
|
+
// 默认清理 details-file,避免项目根目录被临时 JSON 污染;--keep-details 保留调试用
|
|
3618
|
+
if (detailsFilePath && !args['keep-details'] && !args.keep_details) {
|
|
3619
|
+
try {
|
|
3620
|
+
const fileToRemove = normalizeDetailsFilePath(detailsFilePath, projectRoot);
|
|
3621
|
+
if (fs.existsSync(fileToRemove)) {
|
|
3622
|
+
fs.unlinkSync(fileToRemove);
|
|
3623
|
+
}
|
|
3624
|
+
} catch (cleanupErr) {
|
|
3625
|
+
// 清理失败不阻塞主流程
|
|
3626
|
+
try { console.error(`[telemetry] details-file 清理失败(不阻塞): ${cleanupErr.message}`); } catch {}
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
2283
3629
|
} catch (err) {
|
|
2284
3630
|
fail(`details JSON 解析失败: ${err.message}`);
|
|
2285
3631
|
}
|
|
2286
3632
|
|
|
3633
|
+
// task_update 成功时自动同步 tasks.md checkbox
|
|
3634
|
+
if (type === 'task_update' && args.result === 'success') {
|
|
3635
|
+
try {
|
|
3636
|
+
const taskId = args['task-id'] || args.task_id || details?.task_id || null;
|
|
3637
|
+
if (taskId) {
|
|
3638
|
+
runCheckTaskSync(projectRoot, change, taskId);
|
|
3639
|
+
}
|
|
3640
|
+
} catch (taskErr) {
|
|
3641
|
+
// checkbox 同步失败不阻塞原流程
|
|
3642
|
+
try { console.error(`[telemetry] tasks.md checkbox 自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
// conformance_review evidence 校验——assertion 必须有可验证证据:
|
|
3647
|
+
// files 含测试文件路径(test/ | .test. | .spec.)→ 强证据;
|
|
3648
|
+
// 或 evidence 文本非空 → 降级证据(浏览器自动化/手动验收等无测试文件场景,与 SKILL.md schema 对齐:空 files[] 须配 evidence);
|
|
3649
|
+
// 两者皆无 → 空口断言拒绝。
|
|
3650
|
+
if (type === 'conformance_review') {
|
|
3651
|
+
const cr = details.conformance_review;
|
|
3652
|
+
if (cr && Array.isArray(cr.assertions)) {
|
|
3653
|
+
for (const a of cr.assertions) {
|
|
3654
|
+
const files = Array.isArray(a.files) ? a.files : [];
|
|
3655
|
+
const hasTestFile = files.some(f => /test[\\/]|\.test\.|\.spec\./.test(String(f)));
|
|
3656
|
+
const hasEvidence = typeof a.evidence === 'string' && a.evidence.trim().length > 0;
|
|
3657
|
+
if (!hasTestFile && !hasEvidence) {
|
|
3658
|
+
fail(`conformance_review assertion "${a.id || ''}" 缺少证据:files 须含测试文件路径(test/ | .test. | .spec.),或填 evidence 文本(如浏览器自动化/手动验收证据摘要)`);
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
// ai_adoption_review 校验——与 conformance_review 对称的记录时硬校验(doc 早已要求必填,见 opsx-apply/SKILL.md §P1-2):
|
|
3665
|
+
// ai_diff.files 必须填产出文件路径数组(非空),不得只发 files_changed 数字;
|
|
3666
|
+
// vcs_mode=readonly(git 仓库)时 added_lines 不得为 null——逼 agent 跑只读 "git diff --numstat HEAD"(apply Git 只读策略允许);
|
|
3667
|
+
// vcs_mode=no-git 时 added_lines 可为 null(无 diff 可统计)。缺失 vcs_mode 字段时放宽(兼容存量事件)。
|
|
3668
|
+
if (type === 'ai_adoption_review') {
|
|
3669
|
+
const adoption = details.ai_adoption || details.ai_adoption_review || details.ai_code_adoption;
|
|
3670
|
+
if (adoption) {
|
|
3671
|
+
const aiDiff = adoption.ai_diff || {};
|
|
3672
|
+
const files = Array.isArray(aiDiff.files) ? aiDiff.files : null;
|
|
3673
|
+
if (!files || files.length === 0) {
|
|
3674
|
+
fail(`ai_adoption_review 的 ai_diff.files 必须填产出文件路径数组(非空),不得只发 files_changed 数字;详见 opsx-apply/reference.md §5.1`);
|
|
3675
|
+
}
|
|
3676
|
+
if (adoption.vcs_mode === 'readonly' && (aiDiff.added_lines === null || aiDiff.added_lines === undefined)) {
|
|
3677
|
+
fail(`ai_adoption_review vcs_mode=readonly 时 ai_diff.added_lines 不得为 null——请用只读 "git diff --numstat HEAD" 取值(apply Git 只读策略允许);vcs_mode=no-git 时可填 null`);
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
2287
3682
|
const dataDir = getDataDir(projectRoot);
|
|
2288
3683
|
const activeStage = findActiveStage(projectRoot, {
|
|
2289
3684
|
session_id: args['session-id'] || args.session_id,
|
|
@@ -2293,6 +3688,32 @@ function cmdRecord(args) {
|
|
|
2293
3688
|
});
|
|
2294
3689
|
const eventId = args['event-id'] || args.event_id || generateEventId();
|
|
2295
3690
|
const timestamp = nowISO();
|
|
3691
|
+
// T1.2: task_update 复用标注 — 同 session 上一条 task_update 的 test_results.duration_ms 一致且时间戳相邻(<5s) → recorded_late(提示性,不阻断)
|
|
3692
|
+
if (type === 'task_update' && details && details.test_results) {
|
|
3693
|
+
try {
|
|
3694
|
+
const sessionId = activeStage?.session_id || args['session-id'] || args.session_id || null;
|
|
3695
|
+
const priorEvents = readEvents(dataDir, change);
|
|
3696
|
+
const priorUpdates = priorEvents.filter((e) =>
|
|
3697
|
+
e.type === 'task_update' && e.session_id === sessionId && e.details && e.details.test_results
|
|
3698
|
+
);
|
|
3699
|
+
if (priorUpdates.length > 0) {
|
|
3700
|
+
// Y3 修复:显式按时间排序取最新一条(原取数组末尾依赖 readEvents 文件排序=时间序,多 jsonl 文件名非时间前缀时可能错序)
|
|
3701
|
+
const sorted = priorUpdates.slice().sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
|
3702
|
+
const last = sorted[sorted.length - 1];
|
|
3703
|
+
const lastTr = last.details.test_results;
|
|
3704
|
+
const thisTr = details.test_results;
|
|
3705
|
+
const lastFp = `${Number(lastTr.passed)}|${Number(lastTr.failed)}|${Number(lastTr.duration_ms)}`;
|
|
3706
|
+
const thisFp = `${Number(thisTr.passed)}|${Number(thisTr.failed)}|${Number(thisTr.duration_ms)}`;
|
|
3707
|
+
// T1.5(v3): 去掉 gap<=5000 相邻限制,改用 passed+failed+duration_ms 三元组指纹(与 detectReusedTestResults 对齐)
|
|
3708
|
+
if (Number.isFinite(Number(lastTr.duration_ms)) && Number.isFinite(Number(thisTr.duration_ms)) && lastFp === thisFp) {
|
|
3709
|
+
details.recorded_late = true;
|
|
3710
|
+
details.reused_fingerprint = thisFp;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
} catch {
|
|
3714
|
+
// recorded_late 检测失败不阻塞记录
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
2296
3717
|
const event = cleanOptionalFields({
|
|
2297
3718
|
schema_version: SCHEMA_VERSION,
|
|
2298
3719
|
event_id: eventId,
|
|
@@ -2323,6 +3744,189 @@ function cmdRecord(args) {
|
|
|
2323
3744
|
}, null, 2));
|
|
2324
3745
|
}
|
|
2325
3746
|
|
|
3747
|
+
/** 解析 node --test 的 TAP 输出为 {passed, failed, skipped}(opsx-apply agent 跑测试后传 --test-stdout) */
|
|
3748
|
+
function parseTestTap(output) {
|
|
3749
|
+
if (!output || typeof output !== 'string') return { passed: 0, failed: 0, skipped: 0 };
|
|
3750
|
+
let passed = 0;
|
|
3751
|
+
let failed = 0;
|
|
3752
|
+
let skipped = 0;
|
|
3753
|
+
for (const line of output.split(/\r?\n/)) {
|
|
3754
|
+
if (/^ok\s+\d+/.test(line)) {
|
|
3755
|
+
if (/#\s*SKIP/i.test(line)) { skipped += 1; continue; }
|
|
3756
|
+
passed += 1;
|
|
3757
|
+
} else if (/^not ok\s+\d+/.test(line)) {
|
|
3758
|
+
if (/#\s*TODO/i.test(line)) { skipped += 1; continue; }
|
|
3759
|
+
failed += 1;
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
return { passed, failed, skipped };
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
/** scan 子命令——输出全量 task_completion checkbox 统计(opsx-check 强制调用,禁止手填 completed/incomplete/total) */
|
|
3766
|
+
function cmdScan(args) {
|
|
3767
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
3768
|
+
const changeName = args.change || args['change-name'];
|
|
3769
|
+
if (!changeName) {
|
|
3770
|
+
console.error('错误: 缺少 --change 参数');
|
|
3771
|
+
process.exit(1);
|
|
3772
|
+
}
|
|
3773
|
+
const result = scanTaskCompletion(projectRoot, changeName);
|
|
3774
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3775
|
+
return result;
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
/** 校验 spec 场景数 == 测试数(spec.md `##### 场景:` 计数 vs 最近 test_results.passed+failed) */
|
|
3779
|
+
function validateScenarioCoverage(projectRoot, changeName, capability) {
|
|
3780
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
3781
|
+
const changeDir = getChangeDir(normalizedRoot, changeName);
|
|
3782
|
+
let specScenarios = 0;
|
|
3783
|
+
const specFiles = discoverFullSpecFiles(changeDir, changeName);
|
|
3784
|
+
for (const spec of specFiles) {
|
|
3785
|
+
if (capability && spec.capability !== capability) continue;
|
|
3786
|
+
let content = '';
|
|
3787
|
+
try { content = fs.readFileSync(spec.source, 'utf8'); } catch { continue; }
|
|
3788
|
+
const matches = content.match(/^#####\s*场景[::]/gm);
|
|
3789
|
+
if (matches) specScenarios += matches.length;
|
|
3790
|
+
}
|
|
3791
|
+
const dataDir = getDataDir(normalizedRoot);
|
|
3792
|
+
const events = readEvents(dataDir, changeName);
|
|
3793
|
+
const testEvents = getTestEvents(events);
|
|
3794
|
+
let testCount = 0;
|
|
3795
|
+
if (testEvents.length > 0) {
|
|
3796
|
+
// 优先取真正含 test_results 的事件(排除 stage_end(test) 等无 results 的事件);
|
|
3797
|
+
// 都无 results 时退回最新 test 事件(testCount 保持 0)
|
|
3798
|
+
const withResults = testEvents.filter(e => getTestResults(e) != null);
|
|
3799
|
+
const pool = withResults.length > 0 ? withResults : testEvents;
|
|
3800
|
+
const latest = pool.slice().sort((a, b) => timestampMs(a) - timestampMs(b)).pop();
|
|
3801
|
+
const tr = getTestResults(latest);
|
|
3802
|
+
if (tr) testCount = (Number(tr.passed) || 0) + (Number(tr.failed) || 0);
|
|
3803
|
+
}
|
|
3804
|
+
return { ok: specScenarios === testCount, spec: specScenarios, test: testCount };
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
function cmdValidateScenario(args) {
|
|
3808
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
3809
|
+
const changeName = args.change || args['change-name'];
|
|
3810
|
+
const capability = args.capability || args['capability-name'];
|
|
3811
|
+
if (!changeName) {
|
|
3812
|
+
console.error('错误: 缺少 --change 参数');
|
|
3813
|
+
process.exit(1);
|
|
3814
|
+
}
|
|
3815
|
+
const result = validateScenarioCoverage(projectRoot, changeName, capability);
|
|
3816
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3817
|
+
return result;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
/** 扫描 src/+test/ 源文件快照(跳过 node_modules/dist/.git/build),返回 {relPath: {hash, lines}} */
|
|
3821
|
+
function scanSourceFiles(projectRoot) {
|
|
3822
|
+
const result = {};
|
|
3823
|
+
const scanDir = (dir, relBase) => {
|
|
3824
|
+
if (!fs.existsSync(dir)) return;
|
|
3825
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
3826
|
+
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git' || entry.name === 'build') continue;
|
|
3827
|
+
const full = path.join(dir, entry.name);
|
|
3828
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
3829
|
+
if (entry.isDirectory()) {
|
|
3830
|
+
scanDir(full, rel);
|
|
3831
|
+
} else if (/\.(js|cjs|mjs|ts|jsx|tsx|py|java|go|rs|rb|php)$/.test(entry.name)) {
|
|
3832
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
3833
|
+
result[rel] = {
|
|
3834
|
+
hash: crypto.createHash('sha1').update(content).digest('hex').slice(0, 16),
|
|
3835
|
+
lines: content.split('\n').length,
|
|
3836
|
+
};
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
};
|
|
3840
|
+
scanDir(path.join(projectRoot, 'src'), 'src');
|
|
3841
|
+
scanDir(path.join(projectRoot, 'test'), 'test');
|
|
3842
|
+
scanDir(path.join(projectRoot, 'tests'), 'tests');
|
|
3843
|
+
return result;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
/** snapshot 子命令——apply stage_start/end 扫描 src/+test/ 文件快照,end 时输出 added/modified/deleted diff */
|
|
3847
|
+
function cmdSnapshot(args) {
|
|
3848
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
3849
|
+
const changeName = args.change || args['change-name'];
|
|
3850
|
+
const mode = args.mode || args['snapshot-mode'];
|
|
3851
|
+
if (!changeName) { console.error('错误: 缺少 --change 参数'); process.exit(1); }
|
|
3852
|
+
if (mode !== 'start' && mode !== 'end') { console.error('错误: 缺少 --mode=start|end'); process.exit(1); }
|
|
3853
|
+
const stateDir = getStateDir(projectRoot);
|
|
3854
|
+
ensureDir(stateDir);
|
|
3855
|
+
const snapshotFile = path.join(stateDir, `apply-${safeChangeName(changeName)}-snapshot.json`);
|
|
3856
|
+
const current = scanSourceFiles(projectRoot);
|
|
3857
|
+
if (mode === 'start') {
|
|
3858
|
+
fs.writeFileSync(snapshotFile, JSON.stringify(current, null, 2), 'utf8');
|
|
3859
|
+
const result = { mode: 'start', files_scanned: Object.keys(current).length };
|
|
3860
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3861
|
+
return result;
|
|
3862
|
+
}
|
|
3863
|
+
let startSnap = {};
|
|
3864
|
+
try { startSnap = JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); } catch {}
|
|
3865
|
+
const added = [], modified = [], deleted = [];
|
|
3866
|
+
let addedLines = 0, deletedLines = 0;
|
|
3867
|
+
for (const [f, info] of Object.entries(current)) {
|
|
3868
|
+
if (!startSnap[f]) {
|
|
3869
|
+
added.push(f);
|
|
3870
|
+
addedLines += info.lines;
|
|
3871
|
+
} else if (info.hash !== startSnap[f].hash) {
|
|
3872
|
+
modified.push(f);
|
|
3873
|
+
addedLines += Math.max(0, info.lines - (startSnap[f].lines || 0));
|
|
3874
|
+
deletedLines += Math.max(0, (startSnap[f].lines || 0) - info.lines);
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
for (const f of Object.keys(startSnap)) {
|
|
3878
|
+
if (!current[f]) {
|
|
3879
|
+
deleted.push(f);
|
|
3880
|
+
deletedLines += startSnap[f].lines || 0;
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
const result = { mode: 'end', added_files: added.length, modified_files: modified.length, deleted_files: deleted.length, added_lines: addedLines, deleted_lines: deletedLines, added, modified, deleted };
|
|
3884
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3885
|
+
return result;
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
/** archive 时聚合 events 生成"变更总结"段追加到 execution-log.md */
|
|
3889
|
+
function appendChangeSummary(projectRoot, changeName, events) {
|
|
3890
|
+
try {
|
|
3891
|
+
const changeDir = getChangeDir(projectRoot, changeName);
|
|
3892
|
+
let logsDir = path.join(changeDir, 'logs');
|
|
3893
|
+
if (!fs.existsSync(changeDir)) {
|
|
3894
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
3895
|
+
if (archiveDir) logsDir = path.join(archiveDir, 'logs');
|
|
3896
|
+
}
|
|
3897
|
+
ensureDir(logsDir);
|
|
3898
|
+
const file = path.join(logsDir, 'execution-log.md');
|
|
3899
|
+
// 复用 computeChangeMetrics.change_summary,消除 execution-log 与报告的 1 vs 2 不一致(T1.3)
|
|
3900
|
+
const metrics = computeChangeMetrics(changeName, events);
|
|
3901
|
+
const cs = metrics && metrics.change_summary ? metrics.change_summary : null;
|
|
3902
|
+
const stageStartCount = cs ? cs.stage_start_count : events.filter(e => e.type === 'stage_start' && !e.orphan).length;
|
|
3903
|
+
const stageEndCount = cs ? cs.stage_end_count : events.filter(e => e.type === 'stage_end' && !e.orphan).length;
|
|
3904
|
+
const repeatCount = cs ? cs.stage_repeat_count : 0;
|
|
3905
|
+
const testPass = cs ? cs.test_pass : null;
|
|
3906
|
+
const testPassStr = testPass ? `${testPass.passed}/${testPass.passed + testPass.failed}` : '无测试数据';
|
|
3907
|
+
let unchecked = 0;
|
|
3908
|
+
try {
|
|
3909
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
3910
|
+
const tc = archiveDir
|
|
3911
|
+
? scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir)
|
|
3912
|
+
: scanTaskCompletion(projectRoot, changeName);
|
|
3913
|
+
unchecked = tc.has_incomplete ? tc.incomplete : 0;
|
|
3914
|
+
} catch {}
|
|
3915
|
+
const summary = [
|
|
3916
|
+
'',
|
|
3917
|
+
'## 变更总结',
|
|
3918
|
+
`- 阶段数:${stageStartCount} start / ${stageEndCount} end`,
|
|
3919
|
+
`- 阶段重复执行次数:${repeatCount}`,
|
|
3920
|
+
`- 测试通过率:${testPassStr}`,
|
|
3921
|
+
`- 遗留问题:${unchecked > 0 ? `${unchecked} 项验收标准未勾选` : '无'}`,
|
|
3922
|
+
'',
|
|
3923
|
+
].join('\n');
|
|
3924
|
+
fs.appendFileSync(file, summary + '\n', 'utf8');
|
|
3925
|
+
} catch (err) {
|
|
3926
|
+
try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
|
|
2326
3930
|
/**
|
|
2327
3931
|
* 从事件文件中查找 start 事件(因为 CLI 无状态,需要从文件回溯)
|
|
2328
3932
|
*/
|
|
@@ -2362,6 +3966,47 @@ function searchEventInDataDir(dataDir, eventId) {
|
|
|
2362
3966
|
return null;
|
|
2363
3967
|
}
|
|
2364
3968
|
|
|
3969
|
+
/** 按 event_id 读取所有 stage_end 事件(用于 cmdEnd 幂等性保护) */
|
|
3970
|
+
function readAllEventEndsById(dataDir, eventId, changeName) {
|
|
3971
|
+
if (!eventId) return [];
|
|
3972
|
+
const eventsDir = path.join(dataDir, 'events');
|
|
3973
|
+
if (!fs.existsSync(eventsDir)) return [];
|
|
3974
|
+
const ends = [];
|
|
3975
|
+
|
|
3976
|
+
const scanDir = (dir) => {
|
|
3977
|
+
if (!fs.existsSync(dir)) return;
|
|
3978
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort().reverse();
|
|
3979
|
+
for (const file of files) {
|
|
3980
|
+
const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean);
|
|
3981
|
+
for (const line of lines) {
|
|
3982
|
+
try {
|
|
3983
|
+
const event = JSON.parse(line);
|
|
3984
|
+
if (event.event_id === eventId && event.type === 'stage_end') {
|
|
3985
|
+
ends.push(event);
|
|
3986
|
+
}
|
|
3987
|
+
} catch {}
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
};
|
|
3991
|
+
|
|
3992
|
+
// 优先扫描指定 change 目录,命中即返回
|
|
3993
|
+
if (changeName) {
|
|
3994
|
+
scanDir(path.join(eventsDir, changeName));
|
|
3995
|
+
if (ends.length > 0) return ends;
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
// 未命中时回退全量扫描
|
|
3999
|
+
try {
|
|
4000
|
+
const changeDirs = fs.readdirSync(eventsDir).filter(d => {
|
|
4001
|
+
return fs.statSync(path.join(eventsDir, d)).isDirectory();
|
|
4002
|
+
});
|
|
4003
|
+
for (const changeDir of changeDirs) {
|
|
4004
|
+
scanDir(path.join(eventsDir, changeDir));
|
|
4005
|
+
}
|
|
4006
|
+
} catch {}
|
|
4007
|
+
return ends;
|
|
4008
|
+
}
|
|
4009
|
+
|
|
2365
4010
|
/**
|
|
2366
4011
|
* log metrics: 查询度量指标
|
|
2367
4012
|
*/
|
|
@@ -2414,7 +4059,7 @@ function cmdReport(args) {
|
|
|
2414
4059
|
const capability = args.capability || args['capability-name'];
|
|
2415
4060
|
const dateFrom = args['date-from'];
|
|
2416
4061
|
const dateTo = args['date-to'];
|
|
2417
|
-
const format = args.format || '
|
|
4062
|
+
const format = args.format || 'html';
|
|
2418
4063
|
const outputPath = args.output || args['output-file'];
|
|
2419
4064
|
const level = args.level || (capability ? 'capability' : (changeName ? 'change' : 'project'));
|
|
2420
4065
|
|
|
@@ -2429,7 +4074,9 @@ function cmdReport(args) {
|
|
|
2429
4074
|
});
|
|
2430
4075
|
const rendered = format === 'json'
|
|
2431
4076
|
? JSON.stringify(report, null, 2)
|
|
2432
|
-
:
|
|
4077
|
+
: format === 'html'
|
|
4078
|
+
? renderExecutiveReportHtml(report)
|
|
4079
|
+
: renderExecutiveReportMarkdown(report);
|
|
2433
4080
|
|
|
2434
4081
|
if (outputPath) {
|
|
2435
4082
|
const resolvedOutput = path.isAbsolute(outputPath)
|
|
@@ -2461,7 +4108,7 @@ function cmdDoctor(args) {
|
|
|
2461
4108
|
let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
|
|
2462
4109
|
events = filterEventsByDate(events, dateFrom, dateTo);
|
|
2463
4110
|
|
|
2464
|
-
const report = computeDoctorReport(events, { change: changeName });
|
|
4111
|
+
const report = computeDoctorReport(events, { change: changeName, projectRoot });
|
|
2465
4112
|
console.log(JSON.stringify(report, null, 2));
|
|
2466
4113
|
if (report.severe_issues.length > 0) {
|
|
2467
4114
|
process.exitCode = 1;
|
|
@@ -2492,8 +4139,7 @@ function cmdTasksStatus(args) {
|
|
|
2492
4139
|
function cmdArchiveDocs(args) {
|
|
2493
4140
|
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2494
4141
|
const changeName = args.change || args['change-name'];
|
|
2495
|
-
const reportOutput = args['report-output'] || args.report_output ||
|
|
2496
|
-
(changeName ? path.join('skywalk-sdd', 'reports', `${safeChangeName(changeName)}-report.md`) : undefined);
|
|
4142
|
+
const reportOutput = args['report-output'] || args.report_output || '';
|
|
2497
4143
|
|
|
2498
4144
|
try {
|
|
2499
4145
|
let result;
|
|
@@ -2507,7 +4153,7 @@ function cmdArchiveDocs(args) {
|
|
|
2507
4153
|
} else {
|
|
2508
4154
|
const repaired = ensureArchiveSuccessArtifacts(projectRoot, changeName, {
|
|
2509
4155
|
archive_result: {
|
|
2510
|
-
reason:
|
|
4156
|
+
reason: '',
|
|
2511
4157
|
report_path: reportOutput,
|
|
2512
4158
|
},
|
|
2513
4159
|
}, {
|
|
@@ -2529,6 +4175,8 @@ function cmdArchiveDocs(args) {
|
|
|
2529
4175
|
if (!args['keep-active']) {
|
|
2530
4176
|
stageEnd = cmdEnd({
|
|
2531
4177
|
...args,
|
|
4178
|
+
// 归档原因(args.reason)走 details.archive_result.reason,不应作为 cmdEnd 的返工 --reason(枚举)传入
|
|
4179
|
+
reason: undefined,
|
|
2532
4180
|
project: projectRoot,
|
|
2533
4181
|
command: 'archive',
|
|
2534
4182
|
change: changeName,
|
|
@@ -2537,7 +4185,7 @@ function cmdArchiveDocs(args) {
|
|
|
2537
4185
|
details: {
|
|
2538
4186
|
...(args.details && typeof args.details === 'object' ? args.details : {}),
|
|
2539
4187
|
archive_result: {
|
|
2540
|
-
reason: args.reason || '',
|
|
4188
|
+
reason: result.reason || args.reason || '',
|
|
2541
4189
|
method: result.method,
|
|
2542
4190
|
archive_path: result.archive_path,
|
|
2543
4191
|
report_path: reportOutput,
|
|
@@ -2597,9 +4245,21 @@ function main() {
|
|
|
2597
4245
|
case 'tasks-status':
|
|
2598
4246
|
cmdTasksStatus(flags);
|
|
2599
4247
|
break;
|
|
4248
|
+
case 'scan':
|
|
4249
|
+
cmdScan(flags);
|
|
4250
|
+
break;
|
|
4251
|
+
case 'validate-scenario':
|
|
4252
|
+
cmdValidateScenario(flags);
|
|
4253
|
+
break;
|
|
4254
|
+
case 'snapshot':
|
|
4255
|
+
cmdSnapshot(flags);
|
|
4256
|
+
break;
|
|
2600
4257
|
case 'archive-docs':
|
|
2601
4258
|
cmdArchiveDocs(flags);
|
|
2602
4259
|
break;
|
|
4260
|
+
case 'check-task':
|
|
4261
|
+
cmdCheckTask(flags);
|
|
4262
|
+
break;
|
|
2603
4263
|
default:
|
|
2604
4264
|
showHelp();
|
|
2605
4265
|
process.exit(1);
|
|
@@ -2614,8 +4274,9 @@ SDD Telemetry CLI - 流程度量采集工具
|
|
|
2614
4274
|
node skywalk-sdd/log.cjs start --command=<cmd> --project=<path> [--change=<name>] [--agent=<type>]
|
|
2615
4275
|
node skywalk-sdd/log.cjs end --event-id=<id> --result=<success|failure|partial> --summary="..."
|
|
2616
4276
|
node skywalk-sdd/log.cjs metrics --project=<path> [--change=<name>] [--pdf-mvp] [--format=json|markdown]
|
|
2617
|
-
node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=
|
|
4277
|
+
node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=html|markdown|json] [--output=<file>](默认 html)
|
|
2618
4278
|
node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
|
|
4279
|
+
node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
|
|
2619
4280
|
node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
|
|
2620
4281
|
|
|
2621
4282
|
子命令:
|
|
@@ -2626,21 +4287,24 @@ SDD Telemetry CLI - 流程度量采集工具
|
|
|
2626
4287
|
report 生成只读度量报告(不写入事件)
|
|
2627
4288
|
doctor 诊断 Telemetry 数据质量
|
|
2628
4289
|
tasks-status 扫描 Full/Simple 模式 tasks.md 勾选状态
|
|
4290
|
+
check-task 扫描变更目录 tasks.md 并勾选指定 task_id
|
|
2629
4291
|
archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
|
|
2630
4292
|
|
|
2631
4293
|
示例:
|
|
2632
4294
|
node skywalk-sdd/log.cjs start --command=propose --project=/my/project --change=user-auth --agent=cursor
|
|
2633
4295
|
node skywalk-sdd/log.cjs end --event-id=evt_abc123 --result=success --summary="创建 proposal.md"
|
|
2634
|
-
node skywalk-sdd/log.cjs record --type=task_update --command=apply --project=/my/project --change=user-auth --task-id=TASK-01 --status=completed
|
|
2635
|
-
node skywalk-sdd/log.cjs record --type=conformance_review --command=check --project=/my/project --change=user-auth --source=manual --details-file=conformance-review.json
|
|
2636
|
-
node skywalk-sdd/log.cjs record --type=ai_adoption_review --command=apply --project=/my/project --change=user-auth --status=final --details-file=ai-adoption.json
|
|
2637
|
-
node skywalk-sdd/log.cjs record --type=survey_result --project=/my/project --change=user-auth --source=manual --details-file=survey.json
|
|
4296
|
+
node skywalk-sdd/log.cjs record --type=task_update --command=apply --project=/my/project --change=user-auth --task-id=TASK-01 --status=completed --keep-details
|
|
4297
|
+
node skywalk-sdd/log.cjs record --type=conformance_review --command=check --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-conformance-review.json --keep-details
|
|
4298
|
+
node skywalk-sdd/log.cjs record --type=ai_adoption_review --command=apply --project=/my/project --change=user-auth --status=final --details-file=skywalk-sdd/state/user-auth-ai-adoption.json --keep-details
|
|
4299
|
+
node skywalk-sdd/log.cjs record --type=survey_result --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-survey.json --keep-details
|
|
2638
4300
|
node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth
|
|
2639
4301
|
node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth --pdf-mvp --format=markdown
|
|
2640
4302
|
node skywalk-sdd/log.cjs report --project=/my/project --change=user-auth --format=markdown
|
|
2641
4303
|
node skywalk-sdd/log.cjs doctor --project=/my/project --change=user-auth
|
|
2642
4304
|
node skywalk-sdd/log.cjs tasks-status --project=/my/project --change=user-auth --require-complete
|
|
2643
|
-
node skywalk-sdd/log.cjs
|
|
4305
|
+
node skywalk-sdd/log.cjs check-task --project=/my/project --change=user-auth --task-id=TASK-01
|
|
4306
|
+
node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive
|
|
4307
|
+
# archive-docs 最终报告默认生成到 openspec/changes/archive/<日期>-<change>/reports/<change>-report.md;可用 --report-output=<路径> 指定自定义路径
|
|
2644
4308
|
node skywalk-sdd/log.cjs record --type=worktree_finish --command=apply --project=/my/project --change=user-auth --capability=user-auth --result=success --summary="merge+remove completed"
|
|
2645
4309
|
|
|
2646
4310
|
Apply worktree(主仓库根目录,非 log.cjs 子命令):
|
|
@@ -2656,6 +4320,7 @@ module.exports = {
|
|
|
2656
4320
|
parseArgs,
|
|
2657
4321
|
cmdStart,
|
|
2658
4322
|
cmdEnd,
|
|
4323
|
+
cmdCheckTask,
|
|
2659
4324
|
cmdRecord,
|
|
2660
4325
|
cmdMetrics,
|
|
2661
4326
|
cmdReport,
|
|
@@ -2669,6 +4334,11 @@ module.exports = {
|
|
|
2669
4334
|
computePdfMvpMetrics,
|
|
2670
4335
|
renderPdfMvpMarkdown,
|
|
2671
4336
|
renderExecutiveReportMarkdown,
|
|
4337
|
+
renderExecutiveReportHtml,
|
|
4338
|
+
formatMsHuman,
|
|
4339
|
+
formatMsToSeconds,
|
|
4340
|
+
formatDuration,
|
|
4341
|
+
formatReworkReasons,
|
|
2672
4342
|
buildReport,
|
|
2673
4343
|
computeGitDocumentMetrics,
|
|
2674
4344
|
getCheckResults,
|
|
@@ -2676,6 +4346,8 @@ module.exports = {
|
|
|
2676
4346
|
getTestResults,
|
|
2677
4347
|
getTaskUpdateResult,
|
|
2678
4348
|
computeAiFirstPassMetrics,
|
|
4349
|
+
extractProcessNotes,
|
|
4350
|
+
extractStageTimeline,
|
|
2679
4351
|
getSpecTestCoverage,
|
|
2680
4352
|
getSpecTestCoverageEvents,
|
|
2681
4353
|
computeSpecTestCoverageMetrics,
|
|
@@ -2690,6 +4362,12 @@ module.exports = {
|
|
|
2690
4362
|
computeManualInsightMetrics,
|
|
2691
4363
|
computeDoctorReport,
|
|
2692
4364
|
scanTaskCompletion,
|
|
4365
|
+
cmdScan,
|
|
4366
|
+
cmdValidateScenario,
|
|
4367
|
+
validateScenarioCoverage,
|
|
4368
|
+
parseTestTap,
|
|
4369
|
+
cmdSnapshot,
|
|
4370
|
+
appendChangeSummary,
|
|
2693
4371
|
archiveChangeDocs,
|
|
2694
4372
|
filterEventsByDate,
|
|
2695
4373
|
readEvents,
|
|
@@ -2705,6 +4383,7 @@ module.exports = {
|
|
|
2705
4383
|
findActiveStage,
|
|
2706
4384
|
clearActiveStage,
|
|
2707
4385
|
findStartEvent,
|
|
4386
|
+
runCheckTaskSync,
|
|
2708
4387
|
};
|
|
2709
4388
|
|
|
2710
4389
|
// 直接运行时执行
|