sillyspec 3.28.8 → 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-execute/SKILL.md +222 -224
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
- 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/change-risk-profile.js +20 -0
- package/src/config-schema.js +32 -0
- package/src/db.js +11 -3
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-gate.js +105 -6
- package/src/doctor-diagnostics.js +224 -2
- package/src/friction-ledger.js +171 -0
- package/src/hooks/worktree-guard.js +13 -1
- package/src/index.js +188 -17
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/progress/change-registry.js +183 -8
- package/src/progress/shared.js +3 -2
- package/src/progress.js +100 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +155 -14
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +107 -12
- package/src/run/complete-handlers.js +511 -21
- package/src/run/complete.js +54 -16
- package/src/run/concurrent-detect.js +98 -0
- package/src/run/gates.js +50 -9
- package/src/run/prompt.js +134 -5
- package/src/run/quick-audit.js +111 -13
- package/src/run/shared.js +76 -10
- package/src/run/stage.js +6 -0
- 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/stage-contract.js +185 -2
- package/src/stage-review.js +23 -7
- package/src/stages/brainstorm.js +6 -0
- package/src/stages/execute.js +85 -3
- package/src/stages/knowledge.js +52 -3
- 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 +135 -7
- package/src/task-review.js +292 -21
- package/src/verify-postcheck.js +20 -0
- package/src/verify-probes.js +340 -4
- package/src/worktree-apply.js +538 -44
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +117 -5
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/.patch16-tmp.mjs +0 -169
- 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 = {
|
|
@@ -908,6 +910,221 @@ function detectDecisionDrift(cwd) {
|
|
|
908
910
|
}
|
|
909
911
|
}
|
|
910
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
|
+
|
|
911
1128
|
/**
|
|
912
1129
|
* renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
|
|
913
1130
|
* ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
|
|
@@ -955,7 +1172,12 @@ export async function runDoctorDiagnostics({ cwd }) {
|
|
|
955
1172
|
const worktreeHealth = detectWorktreeHealth(cwd)
|
|
956
1173
|
const buildEnv = detectBuildEnv(cwd)
|
|
957
1174
|
const mcpEndpoints = detectMcpEndpoints(cwd)
|
|
958
|
-
|
|
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];
|
|
959
1181
|
|
|
960
1182
|
return {
|
|
961
1183
|
dimensions,
|
|
@@ -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
|
+
}
|
|
@@ -203,6 +203,14 @@ function shouldBlockScanDocOverwrite(filePath, projectRoot) {
|
|
|
203
203
|
const guard = readScanGuard(scanDocInfo, projectRoot)
|
|
204
204
|
if (!guard || guard.forceRescan) return { blocked: false }
|
|
205
205
|
|
|
206
|
+
// 增量刷新会话态前置分支(2026-09-14-scan-incremental-refresh D-007@v1):refresh ①拍写入
|
|
207
|
+
// mode='scan-refresh' + refreshDocs 白名单——白名单内=本次刷新工单面,agent 手术编辑放行;
|
|
208
|
+
// 白名单外的 scan 文档继续走原保护(非本次刷新面不放松)。存量 guard(无 mode 字段)不受影响。
|
|
209
|
+
if (guard.mode === 'scan-refresh' && Array.isArray(guard.refreshDocs)) {
|
|
210
|
+
const refreshRel = toPosixPath(path.relative(scanDocInfo.specRoot, filePath))
|
|
211
|
+
if (guard.refreshDocs.includes(refreshRel)) return { blocked: false }
|
|
212
|
+
}
|
|
213
|
+
|
|
206
214
|
let frontmatter = {}
|
|
207
215
|
try {
|
|
208
216
|
frontmatter = parseFrontmatter(readFileSync(filePath, 'utf8'))
|
|
@@ -211,7 +219,11 @@ function shouldBlockScanDocOverwrite(filePath, projectRoot) {
|
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
const relPath = toPosixPath(path.relative(projectRoot, filePath))
|
|
214
|
-
|
|
222
|
+
// 归一化比对(D-007@v1 顺带修复):guard 写入方用 40 位全哈希(run/stage.js rev-parse HEAD)、
|
|
223
|
+
// frontmatter 盖章用 7 位短哈希(scan-postcheck.js --short)——旧精确比对恒不等,guard 存在
|
|
224
|
+
// 即恒拦。两侧截 7 位后恢复「同基线放行 / 异基线拦截」设计本意。
|
|
225
|
+
if (frontmatter.source_commit && guard.sourceCommit
|
|
226
|
+
&& String(frontmatter.source_commit).slice(0, 7) !== String(guard.sourceCommit).slice(0, 7)) {
|
|
215
227
|
return {
|
|
216
228
|
blocked: true,
|
|
217
229
|
reason: [
|