sillyspec 3.28.7 → 3.28.9
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/.claude/skills/sillyspec-quick/SKILL.md +2 -0
- package/.husky/pre-push +3 -1
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/change-risk-profile.js +20 -0
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +32 -0
- package/src/constants.js +3 -0
- package/src/db.js +23 -6
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +108 -6
- package/src/doctor-diagnostics.js +243 -9
- package/src/friction-ledger.js +171 -0
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +72 -30
- package/src/index.js +356 -81
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +224 -43
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/shared.js +3 -2
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +123 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +199 -32
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +124 -15
- package/src/run/complete-handlers.js +514 -24
- package/src/run/complete.js +82 -25
- package/src/run/concurrent-detect.js +99 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +85 -14
- package/src/run/prompt.js +143 -6
- package/src/run/quick-audit.js +136 -15
- package/src/run/shared.js +174 -20
- package/src/run/stage.js +6 -0
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +365 -35
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +29 -12
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +91 -4
- package/src/stages/knowledge.js +52 -3
- package/src/stages/plan-postcheck.js +57 -12
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +114 -6
- package/src/sync.js +201 -11
- package/src/task-review.js +333 -27
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +41 -6
- package/src/verify-probes.js +348 -7
- package/src/worktree-apply.js +594 -62
- package/src/worktree-cross.js +12 -18
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +255 -30
- package/src/wt-commit.js +128 -0
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/sa-rename-probe/src/new.js +0 -4
|
@@ -24,13 +24,15 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
|
|
26
26
|
import { safeGit } from './git-helper.js';
|
|
27
|
+
import { createHash } from 'crypto';
|
|
27
28
|
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
|
|
28
29
|
import { join, relative, resolve, dirname } from 'path';
|
|
29
30
|
import { gitQuiet } from './git-helper.js'
|
|
30
31
|
import jsYaml from 'js-yaml';
|
|
31
32
|
import { pruneTimestampedEntries } from './runtime-hygiene.js';
|
|
32
33
|
import { CHECK_SEVERITY } from './constants.js';
|
|
33
|
-
import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
|
|
34
|
+
import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE, resolveRuntimeRoot } from './run/shared.js';
|
|
35
|
+
import { readFrictionLedger } from './friction-ledger.js';
|
|
34
36
|
|
|
35
37
|
// db 角色标签
|
|
36
38
|
const DB_ROLE = {
|
|
@@ -856,7 +858,7 @@ export function detectMcpEndpoints(cwd) {
|
|
|
856
858
|
* _module-map.yaml 的 needs_review=true 清单(卡缺失/精确校验归 modules rebuild/status)。
|
|
857
859
|
* 只读 fail-soft。
|
|
858
860
|
*/
|
|
859
|
-
function detectModuleDocHealth(cwd) {
|
|
861
|
+
export function detectModuleDocHealth(cwd) {
|
|
860
862
|
const base = { name: 'module_doc_health', label: '模块文档健康', safe_actions: [] }
|
|
861
863
|
try {
|
|
862
864
|
let mapText = null
|
|
@@ -870,7 +872,8 @@ function detectModuleDocHealth(cwd) {
|
|
|
870
872
|
if (!mapText) return { ...base, findings: ['无 _module-map.yaml(未 scan)——skipped'], pass: true, severity: null, skipped: true }
|
|
871
873
|
const findings = []
|
|
872
874
|
// needs_review 清单(模块块内 needs_review: true)
|
|
873
|
-
|
|
875
|
+
// \\w 是「字面反斜杠+w」——永不分割,整文件当一块只查首个模块(ql-20260911-029)
|
|
876
|
+
const blocks = mapText.split(/\n(?= \w)/)
|
|
874
877
|
for (const blk of blocks) {
|
|
875
878
|
const idM = blk.match(/^ ([\w.-]+):/)
|
|
876
879
|
if (idM && /needs_review:\s*true/.test(blk)) {
|
|
@@ -907,6 +910,221 @@ function detectDecisionDrift(cwd) {
|
|
|
907
910
|
}
|
|
908
911
|
}
|
|
909
912
|
|
|
913
|
+
/**
|
|
914
|
+
* apply-manifest 漂移检测(task-03 / FR-04 / D-005@v1,2026-09-14-apply-conflict-hardening)。
|
|
915
|
+
*
|
|
916
|
+
* 背景:apply 落地的交付文件可被并行会话的工作区级 git 操作(restore/clean 类)冲掉
|
|
917
|
+
* (troubleshooting §64),apply 成功尾声由 writeApplyManifest 落 apply-manifest.json
|
|
918
|
+
* 指纹(文件→sha256),本检查消费该指纹做 apply 后丢失/篡改检测。
|
|
919
|
+
*
|
|
920
|
+
* 扫描面(R-03 对齐):活跃 changes/<变更目录>/apply-manifest.json ∪ 归档
|
|
921
|
+
* changes/archive/<变更目录>/apply-manifest.json 两目录 glob 统一收集(目录移动即换
|
|
922
|
+
* 扫描桶,manifest 自身 change
|
|
923
|
+
* 字段仅作显示名),按 appliedAt 降序取前 5。
|
|
924
|
+
*
|
|
925
|
+
* 哈希口径(design 接口定义定案):manifest 的 sha256 = staged blob 内容(LF 规范态)。
|
|
926
|
+
* staged 态 = git show :<path> 内容直接 sha256(与写侧天然同基);worktree 态 =
|
|
927
|
+
* readFile 后 CRLF→LF 归一(latin1 往返保字节)再算——防 autocrlf=true 工作区 CRLF
|
|
928
|
+
* 与 blob LF 恒异误报。git blob hash 是 sha1,与指纹 sha256 异构不可直比,故两态均
|
|
929
|
+
* 自算内容 sha256。approx:true 条目(写侧 staged 不可用、指纹取自磁盘的 fail-open 面)
|
|
930
|
+
* 跳过 staged 比对——指纹基不是 staged,比了必然误报。
|
|
931
|
+
*
|
|
932
|
+
* 三分支判定(advisory / WARNING,不阻断 doctor 既有退出码语义):
|
|
933
|
+
* ①worktree≠manifest → 落盘面漂移(丢失/被改);②staged≠manifest → 暂存面漂移
|
|
934
|
+
* (index 被动过);③盘上与暂存区皆无 → 丢失。
|
|
935
|
+
* 单文件/单 manifest 异常 fail-open 跳过(坏一个文件不炸整个 doctor);无 manifest
|
|
936
|
+
* 零告警(skipped);doctor 只读不写。
|
|
937
|
+
*/
|
|
938
|
+
function detectApplyManifestDrift(cwd, specDir) {
|
|
939
|
+
const base = { name: 'apply_manifest_drift', label: 'apply 交付指纹漂移', safe_actions: [] }
|
|
940
|
+
try {
|
|
941
|
+
if (!specDir) {
|
|
942
|
+
return { ...base, findings: ['无权威 spec 目录,跳过 apply 指纹面——skipped'], pass: true, severity: null, skipped: true }
|
|
943
|
+
}
|
|
944
|
+
// 两桶 glob 收集:活跃 changes/ + 归档 changes/archive/
|
|
945
|
+
const manifests = []
|
|
946
|
+
let malformed = 0
|
|
947
|
+
const buckets = [join(specDir, 'changes')]
|
|
948
|
+
for (const bucket of buckets) {
|
|
949
|
+
if (!existsSync(bucket)) continue
|
|
950
|
+
// 归档桶(R-03:归档后补 apply 的 manifest 写在 archive/ 下,一并入扫描面)
|
|
951
|
+
const scanDirs = [{ dir: bucket, skip: 'archive' }]
|
|
952
|
+
const archiveDir = join(bucket, 'archive')
|
|
953
|
+
if (existsSync(archiveDir)) scanDirs.push({ dir: archiveDir, skip: null })
|
|
954
|
+
for (const { dir, skip } of scanDirs) {
|
|
955
|
+
let entries
|
|
956
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
957
|
+
for (const e of entries) {
|
|
958
|
+
if (!e.isDirectory() || (skip && e.name === skip)) continue
|
|
959
|
+
const manifestPath = join(dir, e.name, 'apply-manifest.json')
|
|
960
|
+
if (!existsSync(manifestPath)) continue
|
|
961
|
+
try {
|
|
962
|
+
const m = JSON.parse(readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, ''))
|
|
963
|
+
if (!m || !Array.isArray(m.files)) { malformed++; continue }
|
|
964
|
+
manifests.push({ dirName: e.name, change: typeof m.change === 'string' && m.change ? m.change : e.name, appliedAt: m.appliedAt, files: m.files })
|
|
965
|
+
} catch { malformed++; continue } // 单 manifest 坏(JSON/形状)→ fail-open 跳过
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (manifests.length === 0) {
|
|
970
|
+
const findings = ['无 apply-manifest.json(无 apply 指纹面)——skipped']
|
|
971
|
+
if (malformed > 0) findings.push(`${malformed} 份 manifest 不可解析已跳过`)
|
|
972
|
+
return { ...base, findings, pass: true, severity: null, skipped: true }
|
|
973
|
+
}
|
|
974
|
+
// appliedAt 降序取前 5(最新 apply 优先;不可解析时间戳沉底)
|
|
975
|
+
manifests.sort((a, b) => (Date.parse(b.appliedAt) || 0) - (Date.parse(a.appliedAt) || 0))
|
|
976
|
+
const scanned = manifests.slice(0, 5)
|
|
977
|
+
const findings = []
|
|
978
|
+
let checked = 0
|
|
979
|
+
for (const m of scanned) {
|
|
980
|
+
for (const ent of m.files) {
|
|
981
|
+
if (!ent || typeof ent.path !== 'string' || typeof ent.sha256 !== 'string') continue // 坏条目跳过
|
|
982
|
+
checked++
|
|
983
|
+
const short = (h) => (h || '').slice(0, 8)
|
|
984
|
+
// worktree 态:readFile 后 CRLF→LF 归一(latin1 往返保字节,防 autocrlf 误报)
|
|
985
|
+
let worktreeHash = null
|
|
986
|
+
try {
|
|
987
|
+
const raw = readFileSync(join(cwd, ent.path))
|
|
988
|
+
worktreeHash = createHash('sha256')
|
|
989
|
+
.update(Buffer.from(raw.toString('latin1').replace(/\r\n/g, '\n'), 'latin1'))
|
|
990
|
+
.digest('hex')
|
|
991
|
+
} catch { /* 盘上无 → 丢失分支候选 */ }
|
|
992
|
+
// staged 态:git show :<path> buffer 直接 sha256(staged blob 即 LF 规范态,与写侧同基)
|
|
993
|
+
let stagedHash = null
|
|
994
|
+
const blob = gitQuiet(cwd, ['show', `:${ent.path}`], { encoding: 'buffer', timeout: 15000 })
|
|
995
|
+
if (Buffer.isBuffer(blob)) stagedHash = createHash('sha256').update(blob).digest('hex')
|
|
996
|
+
const tag = `${m.change}×${ent.path}`
|
|
997
|
+
if (worktreeHash === null && stagedHash === null) {
|
|
998
|
+
findings.push(`${tag} 丢失(盘上与暂存区皆无;期望 ${short(ent.sha256)})`)
|
|
999
|
+
continue
|
|
1000
|
+
}
|
|
1001
|
+
if (worktreeHash !== null && worktreeHash !== ent.sha256) {
|
|
1002
|
+
findings.push(`${tag} 落盘面漂移(期望 ${short(ent.sha256)} 实际 ${short(worktreeHash)})`)
|
|
1003
|
+
}
|
|
1004
|
+
if (!ent.approx && stagedHash !== null && stagedHash !== ent.sha256) {
|
|
1005
|
+
findings.push(`${tag} 暂存面漂移(期望 ${short(ent.sha256)} 实际 ${short(stagedHash)})`)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (malformed > 0) findings.push(`${malformed} 份 manifest 不可解析已跳过`)
|
|
1010
|
+
if (findings.length === 0) {
|
|
1011
|
+
return { ...base, findings: [`apply 指纹面一致(最近 ${scanned.length} 份 manifest × ${checked} 文件零漂移)`], pass: true, severity: CHECK_SEVERITY.PASSED }
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
...base,
|
|
1015
|
+
findings,
|
|
1016
|
+
pass: false,
|
|
1017
|
+
severity: CHECK_SEVERITY.WARNING,
|
|
1018
|
+
safe_actions: [{
|
|
1019
|
+
dimension: 'apply_manifest_drift',
|
|
1020
|
+
action: 'inspect_drifted_files',
|
|
1021
|
+
risk: 'manual_edit',
|
|
1022
|
+
rationale: 'apply 落地文件与指纹不符(丢失/被改/暂存区被动过)',
|
|
1023
|
+
next_step: '对照 worktree(唯一可信源)逐文件判定直拷/手并,落地后立即 git add -- <files> 提交锁定',
|
|
1024
|
+
}],
|
|
1025
|
+
}
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ══ 2026-09-15-tax-governance(task-02):自维护税面 self_maintenance_tax(FR-03)══
|
|
1032
|
+
|
|
1033
|
+
/** 税重阈值:单变更累计 total ≥3 记 WARNING(X-07 刻意拉状态——税重提示本就该拉状态) */
|
|
1034
|
+
const TAX_HEAVY_TOTAL = 3;
|
|
1035
|
+
/** 聚合窗口:近 90 天(archivedAt 口径;无 archivedAt = 变更未归档、摩擦是当前进行时,计入窗口) */
|
|
1036
|
+
const TAX_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
|
|
1037
|
+
/** 近窗口聚合展示条数 */
|
|
1038
|
+
const TAX_TOP_N = 5;
|
|
1039
|
+
/** 类型→中文标签(与 friction-tally.js FRICTION_LABELS 同枚举同序,此处本地复制不动其文件) */
|
|
1040
|
+
const TAX_TYPE_LABELS = [['gate_rollback', 'gate 回滚'], ['verify_run_failed', '验证失败'], ['review_rejected', '审查打回']];
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* 自维护税面(FR-03,只读):「哪个变更税重」从回忆变成可查数据。
|
|
1044
|
+
* ① 活跃变更非零 tally 列示(runtimeRoot 下 friction-tally-*.json——verify 收尾 consume 清零
|
|
1045
|
+
* 后仍非零 = 有未消费的进行中摩擦):纯信息不拉状态;
|
|
1046
|
+
* ② 台账聚合(friction-ledger.json):近 90 天 total top5 + 累计总量:纯信息不拉状态;
|
|
1047
|
+
* ③ 仅单变更 total≥3 记 WARNING(X-07 刻意——detail 注明评估机制群简化/退役);
|
|
1048
|
+
* ④ 台账缺失渲染「无台账数据(首次归档后生成)」不告警。
|
|
1049
|
+
* severity 语义(Grill X-07):聚合/列示 detail 走 pass:true,只有税重 WARNING 会拉低
|
|
1050
|
+
* overall_status(formatDoctorJson 按 severity 计 warning)。runtimeRoot 与写入侧同源走
|
|
1051
|
+
* resolveRuntimeRoot(X-08 平台模式防分裂,pointer.runtimeRoot 优先、同 gcUnstampedExecuteRuns
|
|
1052
|
+
* 的 existsSync 前置门)。探测只读、全程 fail-soft 降级 skipped。
|
|
1053
|
+
*/
|
|
1054
|
+
export function detectSelfMaintenanceTax(cwd, authoritySpecDir) {
|
|
1055
|
+
const base = { name: 'self_maintenance_tax', label: '自维护税面(摩擦信号台账)', safe_actions: [] };
|
|
1056
|
+
try {
|
|
1057
|
+
const pointer = resolvePointer(cwd);
|
|
1058
|
+
const runtimeRoot = resolveRuntimeRoot(
|
|
1059
|
+
{ runtimeRoot: pointer.present && pointer.runtimeRoot && existsSync(pointer.runtimeRoot) ? pointer.runtimeRoot : null },
|
|
1060
|
+
authoritySpecDir || join(cwd, '.sillyspec'),
|
|
1061
|
+
);
|
|
1062
|
+
const findings = [];
|
|
1063
|
+
const warnings = [];
|
|
1064
|
+
|
|
1065
|
+
// ① 活跃非零 tally 列示(events:{type:{count,lastAt}},X-10 结构)
|
|
1066
|
+
try {
|
|
1067
|
+
for (const f of readdirSync(runtimeRoot)) {
|
|
1068
|
+
const m = f.match(/^friction-tally-(.+)\.json$/);
|
|
1069
|
+
if (!m) continue;
|
|
1070
|
+
let tally = null;
|
|
1071
|
+
try { tally = JSON.parse(readFileSync(join(runtimeRoot, f), 'utf8')); } catch { continue; }
|
|
1072
|
+
const events = tally && typeof tally === 'object' && tally.events && typeof tally.events === 'object' && !Array.isArray(tally.events) ? tally.events : {};
|
|
1073
|
+
const segs = [];
|
|
1074
|
+
let total = 0;
|
|
1075
|
+
let lastAt = '';
|
|
1076
|
+
for (const [type, label] of TAX_TYPE_LABELS) {
|
|
1077
|
+
const n = Number(events[type] && events[type].count);
|
|
1078
|
+
if (!Number.isFinite(n) || n <= 0) continue;
|
|
1079
|
+
segs.push(`${label} ${n}`);
|
|
1080
|
+
total += n;
|
|
1081
|
+
const la = events[type] && typeof events[type].lastAt === 'string' ? events[type].lastAt : '';
|
|
1082
|
+
if (la > lastAt) lastAt = la;
|
|
1083
|
+
}
|
|
1084
|
+
if (total <= 0) continue;
|
|
1085
|
+
findings.push(`活跃摩擦:${m[1]}(${segs.join('、')}${lastAt ? `,最近 ${lastAt}` : ''})`);
|
|
1086
|
+
if (total >= TAX_HEAVY_TOTAL) warnings.push(`税重——评估该机制群简化/退役(对照其 decisions 退役判据):${m[1]}(活跃 total ${total})`);
|
|
1087
|
+
}
|
|
1088
|
+
} catch { /* runtimeRoot 不可读 → 无活跃数据,不误报 */ }
|
|
1089
|
+
|
|
1090
|
+
// ② 台账聚合(近 90 天 top5 + 累计总量);④ 缺失渲染提示不告警
|
|
1091
|
+
const ledger = readFrictionLedger(runtimeRoot);
|
|
1092
|
+
if (ledger.length === 0) {
|
|
1093
|
+
findings.push('无台账数据(首次归档后生成)');
|
|
1094
|
+
} else {
|
|
1095
|
+
const grandTotal = ledger.reduce((s, e) => s + (Number.isFinite(Number(e.total)) ? Number(e.total) : 0), 0);
|
|
1096
|
+
findings.push(`台账累计:${ledger.length} 个变更 / 总摩擦 ${grandTotal} 次`);
|
|
1097
|
+
const now = Date.now();
|
|
1098
|
+
const recent = ledger.filter((e) => {
|
|
1099
|
+
if (typeof e.archivedAt !== 'string' || !e.archivedAt) return true; // 未归档 = 当前进行时
|
|
1100
|
+
const t = Date.parse(e.archivedAt);
|
|
1101
|
+
return Number.isFinite(t) && now - t <= TAX_WINDOW_MS;
|
|
1102
|
+
});
|
|
1103
|
+
if (recent.length === 0) {
|
|
1104
|
+
findings.push('近 90 天无台账条目');
|
|
1105
|
+
} else {
|
|
1106
|
+
const top = [...recent]
|
|
1107
|
+
.sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0))
|
|
1108
|
+
.slice(0, TAX_TOP_N)
|
|
1109
|
+
.map((e) => `${e.change}(${Number(e.total) || 0})`);
|
|
1110
|
+
findings.push(`近 90 天税重 top${top.length}:${top.join('、')}`);
|
|
1111
|
+
}
|
|
1112
|
+
for (const e of ledger) {
|
|
1113
|
+
if ((Number(e.total) || 0) >= TAX_HEAVY_TOTAL) {
|
|
1114
|
+
warnings.push(`税重——评估该机制群简化/退役(对照其 decisions 退役判据):${e.change}(total ${Number(e.total) || 0})`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (warnings.length > 0) {
|
|
1120
|
+
return { ...base, findings: [...warnings, ...findings], pass: false, severity: CHECK_SEVERITY.WARNING };
|
|
1121
|
+
}
|
|
1122
|
+
return { ...base, findings, pass: true, severity: CHECK_SEVERITY.PASSED };
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
910
1128
|
/**
|
|
911
1129
|
* renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
|
|
912
1130
|
* ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
|
|
@@ -954,7 +1172,12 @@ export async function runDoctorDiagnostics({ cwd }) {
|
|
|
954
1172
|
const worktreeHealth = detectWorktreeHealth(cwd)
|
|
955
1173
|
const buildEnv = detectBuildEnv(cwd)
|
|
956
1174
|
const mcpEndpoints = detectMcpEndpoints(cwd)
|
|
957
|
-
|
|
1175
|
+
// task-03(2026-09-14-apply-conflict-hardening):apply-manifest 漂移(advisory,
|
|
1176
|
+
// 扫描面走权威 specDir 定位链,git/文件内容比对走 cwd 主仓面)
|
|
1177
|
+
const applyManifestDrift = detectApplyManifestDrift(cwd, authoritySpecDir)
|
|
1178
|
+
// task-02(2026-09-15-tax-governance):自维护税面(活跃 tally 列示 + 台账聚合 + 税重阈值,只读)
|
|
1179
|
+
const selfMaintenanceTax = detectSelfMaintenanceTax(cwd, authoritySpecDir)
|
|
1180
|
+
const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax];
|
|
958
1181
|
|
|
959
1182
|
return {
|
|
960
1183
|
dimensions,
|
|
@@ -999,13 +1222,24 @@ function detectLifecycleDocStaleness(cwd) {
|
|
|
999
1222
|
return { ...base, pass: true, severity: null, findings: ['文档时间戳不可解析,跳过(不误报)'] };
|
|
1000
1223
|
}
|
|
1001
1224
|
// 生命周期代码侧最新提交(取各敏感路径 max %ct;任一路径 untracked 新文件不在 log 里,
|
|
1002
|
-
// git 不可见性注记——工作树未提交改动本就不该由 doctor
|
|
1225
|
+
// git 不可见性注记——工作树未提交改动本就不该由 doctor 报,提交后自然可见)。
|
|
1226
|
+
// 批 E-②:单次多 pathspec git log 取 max(原逐路径 6 个串行子进程,Windows ~0.4-1s);
|
|
1227
|
+
// 仅当检出「文档落后」需要归因到具体路径时,才退回逐路径解析(罕见告警路径)。
|
|
1003
1228
|
let srcTs = 0;
|
|
1004
1229
|
let srcPath = null;
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1230
|
+
const combined = safeGit(cwd, ['log', '-1', '--format=%ct', '--', ...LIFECYCLE_SENSITIVE_PATHS]);
|
|
1231
|
+
srcTs = Number(combined.value);
|
|
1232
|
+
if (!Number.isFinite(srcTs) || srcTs <= 0) srcTs = 0;
|
|
1233
|
+
if (docTs < srcTs) {
|
|
1234
|
+
// 归因:找 ts === srcTs 的路径(等不到就取最大有限值兜底)
|
|
1235
|
+
let bestTs = 0
|
|
1236
|
+
for (const p of LIFECYCLE_SENSITIVE_PATHS) {
|
|
1237
|
+
const r = safeGit(cwd, ['log', '-1', '--format=%ct', '--', p]);
|
|
1238
|
+
const ts = Number(r.value);
|
|
1239
|
+
if (!Number.isFinite(ts)) continue;
|
|
1240
|
+
if (ts === srcTs) { srcPath = p; break; }
|
|
1241
|
+
if (ts > bestTs) { bestTs = ts; srcPath = p; }
|
|
1242
|
+
}
|
|
1009
1243
|
}
|
|
1010
1244
|
if (srcTs === 0) {
|
|
1011
1245
|
return { ...base, pass: true, severity: null, findings: ['生命周期代码无提交历史,跳过'] };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* friction-ledger.js — 摩擦幸存台账数据层(2026-09-15-tax-governance 方案 B / FR-02)
|
|
3
|
+
*
|
|
4
|
+
* 定位:摩擦历史此前「归档即失忆」——friction tally 在 verify 收尾被 consumeFrictionHint
|
|
5
|
+
* 删除(计数清零),归档时 prune 只见残余,无任何幸存记录,「哪个变更税重」全凭回忆。
|
|
6
|
+
* 本模块把 consume 到的 counts 按 change 滚动合并进 <runtimeRoot>/friction-ledger.json
|
|
7
|
+
* (JSON 数组,D-001@v2),doctor 的 self_maintenance_tax 维度据此做聚合展示与阈值提示。
|
|
8
|
+
*
|
|
9
|
+
* 两个滚动点(design.md 方案 B,Grill P1-1 修订):
|
|
10
|
+
* - 主滚动:complete.js 两处 verify 收尾(consumeFrictionHint 返回 counts 后)走
|
|
11
|
+
* mergeFrictionEntry(withFileLock + writeAtomicSync 读改写,锁先例 friction-tally.js:180-193)
|
|
12
|
+
* - 兜底滚动:complete-handlers.js pruneArchivedChangeRuntime 删 tally 前读残余
|
|
13
|
+
* events[type].count 走 mergeFrictionEntrySync 并落 archivedAt——prune 是同步契约
|
|
14
|
+
* (既有 {ok, removed} 消费方与测试直调),拿不了 async 锁;无锁读改写的并发丢失
|
|
15
|
+
* 风险按 design R-02 P3 容忍(writeAtomicSync 保证无半截文件,坏文件按空数组起)。
|
|
16
|
+
*
|
|
17
|
+
* merge-by-change(Grill P1-1 核心):同 change 已有条目按类型累加合并非双计——verify 可
|
|
18
|
+
* --reopen 重跑,二次 consume 的 counts 累进同一条目;archivedAt 仅 prune 侧落定,主滚动
|
|
19
|
+
* 不落、已有值不冲掉。条目结构(design 接口定义):
|
|
20
|
+
* { change: string, archivedAt?: string, counts: { gate_rollback, verify_run_failed,
|
|
21
|
+
* review_rejected }, total: number }
|
|
22
|
+
* 台账 ≤200 条掐头留最新(rollLedger——防病态体量,不承载完整审计);被 merge 的既有条目
|
|
23
|
+
* 挪到尾部(最近活跃者优先幸存掐头)。
|
|
24
|
+
*
|
|
25
|
+
* 落点红线(同 friction-tally D-002/R-04):台账只落 .runtime 树内(本模块只接收
|
|
26
|
+
* runtimeRoot,调用方经 run/shared.js resolveRuntimeRoot 同源解析——X-08 平台模式防分裂),
|
|
27
|
+
* **永不落 changes/**。
|
|
28
|
+
*
|
|
29
|
+
* fail-soft 红线(design):台账是旁路数据,写失败绝不阻断任何收尾/归档——全程 try/catch
|
|
30
|
+
* 返回 { ok:false } 不抛;文件缺失/坏 JSON 按空数组起(全量重启为容忍立场,D-001@v2)。
|
|
31
|
+
* 本模块自身不打印任何输出。
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, mkdirSync } from 'fs'
|
|
34
|
+
import { dirname, join } from 'path'
|
|
35
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
36
|
+
import { withFileLock } from './quicklog.js'
|
|
37
|
+
|
|
38
|
+
/** 摩擦事件三类型(与 friction-tally.js FRICTION_TYPES 同枚举;固定序即 counts 键序) */
|
|
39
|
+
const FRICTION_TYPES = ['gate_rollback', 'verify_run_failed', 'review_rejected']
|
|
40
|
+
|
|
41
|
+
/** 台账条目上限:超出掐头(最旧)留最新 200 条 */
|
|
42
|
+
const LEDGER_CAP = 200
|
|
43
|
+
|
|
44
|
+
/** 台账文件名(runtimeRoot 下单文件,JSON 数组) */
|
|
45
|
+
const LEDGER_FILENAME = 'friction-ledger.json'
|
|
46
|
+
|
|
47
|
+
function ledgerFilePath(runtimeRoot) {
|
|
48
|
+
return join(runtimeRoot, LEDGER_FILENAME)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 读台账。文件不存在 / 坏 JSON / 非数组 → [](容忍立场:全量重启重新累计,不修复不报错,
|
|
53
|
+
* 同 readFrictionTally)。仅过滤无 change 键的脏条目,其余原样返回(total/counts 由 merge
|
|
54
|
+
* 侧重算,doctor 侧读取时数值兜底)。
|
|
55
|
+
* @param {string} runtimeRoot .runtime 根目录
|
|
56
|
+
* @returns {Array<{change: string, archivedAt?: string, counts: object, total: number}>}
|
|
57
|
+
*/
|
|
58
|
+
export function readFrictionLedger(runtimeRoot) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(readFileSync(ledgerFilePath(runtimeRoot), 'utf8'))
|
|
61
|
+
if (!Array.isArray(raw)) return []
|
|
62
|
+
return raw.filter((e) => e && typeof e === 'object' && !Array.isArray(e) && typeof e.change === 'string' && e.change !== '')
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 掐头留下最新:超过 LEDGER_CAP 条时丢最旧(数组头部),保尾部(最新 append / 最近 merge
|
|
70
|
+
* 挪尾)。纯函数。
|
|
71
|
+
*/
|
|
72
|
+
export function rollLedger(entries) {
|
|
73
|
+
if (!Array.isArray(entries)) return []
|
|
74
|
+
return entries.length > LEDGER_CAP ? entries.slice(entries.length - LEDGER_CAP) : entries
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** counts 入参清洗:只收三枚举类型且为有限正数(consumeFrictionHint 只回非零键,容忍宽松输入) */
|
|
78
|
+
function sanitizeDelta(counts) {
|
|
79
|
+
const delta = {}
|
|
80
|
+
if (!counts || typeof counts !== 'object') return delta
|
|
81
|
+
for (const type of FRICTION_TYPES) {
|
|
82
|
+
const n = Number(counts[type])
|
|
83
|
+
if (Number.isFinite(n) && n > 0) delta[type] = n
|
|
84
|
+
}
|
|
85
|
+
return delta
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 条目 counts 三键定形(缺失/脏型按 0),total 重算 */
|
|
89
|
+
function shapeEntry(change, counts, archivedAt) {
|
|
90
|
+
const shaped = {
|
|
91
|
+
change,
|
|
92
|
+
counts: {
|
|
93
|
+
gate_rollback: counts.gate_rollback || 0,
|
|
94
|
+
verify_run_failed: counts.verify_run_failed || 0,
|
|
95
|
+
review_rejected: counts.review_rejected || 0,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
shaped.total = shaped.counts.gate_rollback + shaped.counts.verify_run_failed + shaped.counts.review_rejected
|
|
99
|
+
if (archivedAt) shaped.archivedAt = archivedAt
|
|
100
|
+
return shaped
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* merge-by-change 纯核心:同 change 已有条目按类型累加(total 重算)非双计,新 change
|
|
105
|
+
* append;archivedAt 有值时落定、无值时保留既有(主滚动不冲掉 prune 已落的归档时间);
|
|
106
|
+
* 被 merge 的既有条目挪到数组尾部(最近活跃者优先幸存掐头)。返回新数组,不改入参。
|
|
107
|
+
*/
|
|
108
|
+
function applyMergeEntry(entries, { change, counts, archivedAt }) {
|
|
109
|
+
const delta = sanitizeDelta(counts)
|
|
110
|
+
const idx = entries.findIndex((e) => e.change === change)
|
|
111
|
+
if (idx === -1) return [...entries, shapeEntry(change, delta, archivedAt)]
|
|
112
|
+
const prev = entries[idx]
|
|
113
|
+
const mergedCounts = {}
|
|
114
|
+
for (const type of FRICTION_TYPES) {
|
|
115
|
+
const p = Number(prev.counts && prev.counts[type])
|
|
116
|
+
mergedCounts[type] = (Number.isFinite(p) ? p : 0) + (delta[type] || 0)
|
|
117
|
+
}
|
|
118
|
+
const keepArchivedAt = archivedAt || (typeof prev.archivedAt === 'string' && prev.archivedAt ? prev.archivedAt : null)
|
|
119
|
+
return [...entries.slice(0, idx), ...entries.slice(idx + 1), shapeEntry(change, mergedCounts, keepArchivedAt)]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 同步滚动一条台账(读 → merge-by-change → 掐头 → writeAtomicSync 落盘),供同步契约调用
|
|
124
|
+
* 方(pruneArchivedChangeRuntime)使用;async 主入口 mergeFrictionEntry 的锁内临界区也是
|
|
125
|
+
* 本函数(单一实现,锁只是外壳)。
|
|
126
|
+
*
|
|
127
|
+
* 空 counts(三键任一都非正)跳过写盘——干净收尾/零残余归档不落空文件(FR-02 验收)。
|
|
128
|
+
* fail-soft:任何异常(只读目录/路径非法/坏 JSON)吞掉返回 { ok:false } 绝不抛。
|
|
129
|
+
* @param {string} runtimeRoot .runtime 根目录
|
|
130
|
+
* @param {{ change: string, counts?: object, archivedAt?: string }} entry
|
|
131
|
+
* @returns {{ ok: boolean, skipped?: boolean, entry?: object }}
|
|
132
|
+
* 成功 { ok:true, skipped:false, entry };空 delta 跳过 { ok:true, skipped:true };失败 { ok:false }
|
|
133
|
+
*/
|
|
134
|
+
export function mergeFrictionEntrySync(runtimeRoot, entry) {
|
|
135
|
+
try {
|
|
136
|
+
if (!runtimeRoot || typeof runtimeRoot !== 'string') return { ok: false, skipped: true }
|
|
137
|
+
const change = entry && typeof entry.change === 'string' ? entry.change : ''
|
|
138
|
+
if (!change) return { ok: false, skipped: true }
|
|
139
|
+
if (Object.keys(sanitizeDelta(entry.counts)).length === 0) return { ok: true, skipped: true }
|
|
140
|
+
const merged = applyMergeEntry(readFrictionLedger(runtimeRoot), {
|
|
141
|
+
change,
|
|
142
|
+
counts: entry.counts,
|
|
143
|
+
archivedAt: typeof entry.archivedAt === 'string' && entry.archivedAt ? entry.archivedAt : null,
|
|
144
|
+
})
|
|
145
|
+
const rolled = rollLedger(merged)
|
|
146
|
+
const path = ledgerFilePath(runtimeRoot)
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
148
|
+
writeAtomicSync(path, JSON.stringify(rolled, null, 2))
|
|
149
|
+
return { ok: true, skipped: false, entry: rolled[rolled.length - 1] }
|
|
150
|
+
} catch {
|
|
151
|
+
return { ok: false }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 台账滚动主入口(complete.js 两处 verify 收尾 consume 后调用):withFileLock 串行化
|
|
157
|
+
* 读改写(锁先例 friction-tally.js:180-193——台账会被收尾/归档进程并发读改,无锁交错
|
|
158
|
+
* 丢更新;坏文件/写失败仍按空数组起 / fail-soft)。锁内即 mergeFrictionEntrySync。
|
|
159
|
+
* @param {string} runtimeRoot .runtime 根目录(调用方 resolveRuntimeRoot 同源解析)
|
|
160
|
+
* @param {{ change: string, counts?: object, archivedAt?: string }} entry
|
|
161
|
+
* @returns {Promise<{ ok: boolean, skipped?: boolean, entry?: object }>} 失败 { ok:false } 不抛
|
|
162
|
+
*/
|
|
163
|
+
export async function mergeFrictionEntry(runtimeRoot, entry) {
|
|
164
|
+
try {
|
|
165
|
+
if (!runtimeRoot || typeof runtimeRoot !== 'string') return { ok: false, skipped: true }
|
|
166
|
+
const path = ledgerFilePath(runtimeRoot)
|
|
167
|
+
return await withFileLock(path + '.lock', () => mergeFrictionEntrySync(runtimeRoot, entry))
|
|
168
|
+
} catch {
|
|
169
|
+
return { ok: false }
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/friction-tally.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
|
27
27
|
import { dirname, join } from 'path'
|
|
28
28
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
29
|
+
import { withFileLock } from './quicklog.js'
|
|
29
30
|
import { resolveRuntimeRoot, resolveQuickSessionsDir } from './run/shared.js'
|
|
30
31
|
|
|
31
32
|
/** 摩擦事件类型枚举(D-004):审查打回走专属 review_rejected,不与 gate_rollback 重复计 */
|
|
@@ -169,13 +170,14 @@ export function renderFrictionHintLine(counts) {
|
|
|
169
170
|
* @param {string} [p.detail] 预定义来源标签(如 verify-test/quick-audit),非字符串落 null
|
|
170
171
|
* @returns {{ type: string, count: number } | null} 落盘成功返回新计数;null = 未落盘
|
|
171
172
|
*/
|
|
172
|
-
export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
|
|
173
|
+
export async function recordFrictionEvent({ cwd, platformOpts, changeName, type, detail }) {
|
|
173
174
|
try {
|
|
174
175
|
if (!FRICTION_TYPES.has(type)) return null
|
|
175
176
|
const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
|
|
176
177
|
if (!tallyPath) return null
|
|
177
178
|
// enabled=false 直通:record/consume 双 no-op,.runtime 零写入(D-003 一键全关)
|
|
178
179
|
if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return null
|
|
180
|
+
return await withFileLock(tallyPath + ".lock", async () => {
|
|
179
181
|
const tally = readFrictionTally(tallyPath)
|
|
180
182
|
const at = new Date().toISOString()
|
|
181
183
|
const prev = Number(tally.events[type] && tally.events[type].count)
|
|
@@ -188,6 +190,7 @@ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detai
|
|
|
188
190
|
mkdirSync(dirname(tallyPath), { recursive: true })
|
|
189
191
|
writeAtomicSync(tallyPath, JSON.stringify(tally, null, 2))
|
|
190
192
|
return { type, count }
|
|
193
|
+
})
|
|
191
194
|
} catch {
|
|
192
195
|
return null
|
|
193
196
|
}
|
|
@@ -201,11 +204,12 @@ export function recordFrictionEvent({ cwd, platformOpts, changeName, type, detai
|
|
|
201
204
|
* 会再消费一次,advisory 容忍重复,不因删失败丢提示。
|
|
202
205
|
* @returns {{ hint: string | null, counts: Record<string, number> }}
|
|
203
206
|
*/
|
|
204
|
-
export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
|
|
207
|
+
export async function consumeFrictionHint({ cwd, platformOpts, changeName }) {
|
|
205
208
|
try {
|
|
206
209
|
const tallyPath = frictionTallyPath({ cwd, platformOpts, changeName })
|
|
207
210
|
if (!tallyPath || !existsSync(tallyPath)) return { hint: null, counts: {} }
|
|
208
211
|
if (!readFrictionHintEnabled(deriveSpecBase(cwd, platformOpts))) return { hint: null, counts: {} }
|
|
212
|
+
return await withFileLock(tallyPath + ".lock", async () => {
|
|
209
213
|
const tally = readFrictionTally(tallyPath)
|
|
210
214
|
const counts = {}
|
|
211
215
|
for (const type of FRICTION_TYPES) {
|
|
@@ -215,6 +219,7 @@ export function consumeFrictionHint({ cwd, platformOpts, changeName }) {
|
|
|
215
219
|
if (Object.keys(counts).length === 0) return { hint: null, counts }
|
|
216
220
|
try { rmSync(tallyPath, { force: true }) } catch { /* 删失败不吞提示 */ }
|
|
217
221
|
return { hint: renderFrictionHintLine(counts), counts }
|
|
222
|
+
})
|
|
218
223
|
} catch {
|
|
219
224
|
return { hint: null, counts: {} }
|
|
220
225
|
}
|
package/src/git-helper.js
CHANGED
|
@@ -124,16 +124,18 @@ export function safeGit(cwd, args, opts = {}) {
|
|
|
124
124
|
* @returns {string|Buffer}
|
|
125
125
|
*/
|
|
126
126
|
export function git(cwd, args, opts = {}) {
|
|
127
|
-
const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
|
|
127
|
+
const { trim = true, timeout = 5000, encoding = 'utf8', env } = opts
|
|
128
128
|
const fullArgs = buildFullArgs(cwd, args)
|
|
129
|
-
|
|
129
|
+
// env(终批-③):可选注入(如 baseline checkpoint 的临时 GIT identity)——调用方必须
|
|
130
|
+
// 用 { ...process.env, GIT_xxx } 展开形式,裸替换会丢 SystemRoot/USERPROFILE/TEMP(Windows)
|
|
131
|
+
const value = execFileSync('git', fullArgs, { encoding, timeout, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'], env })
|
|
130
132
|
if (Buffer.isBuffer(value)) return value
|
|
131
133
|
return trim ? value.trim() : value
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/**
|
|
135
137
|
* 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
|
|
136
|
-
* ENOBUFS(输出超
|
|
138
|
+
* ENOBUFS(输出超 GIT_MAX_BUFFER 上限)不是"无输出",静默成 null 会让调用方把大输出误当空清单——
|
|
137
139
|
* 至少 stderr 留一行,不与正常空结果同语义。
|
|
138
140
|
* @param {string} cwd
|
|
139
141
|
* @param {string[]} args
|
|
@@ -145,7 +147,7 @@ export function gitQuiet(cwd, args, opts = {}) {
|
|
|
145
147
|
return git(cwd, args, opts)
|
|
146
148
|
} catch (e) {
|
|
147
149
|
if (e && e.code === 'ENOBUFS') {
|
|
148
|
-
console.error(`[sillyspec] git 输出超过
|
|
150
|
+
console.error(`[sillyspec] git 输出超过 ${GIT_MAX_BUFFER / (1024 * 1024)}MB 上限被截断:git ${args.join(' ')}`)
|
|
149
151
|
}
|
|
150
152
|
return null
|
|
151
153
|
}
|