focus-guard 0.0.0-stage → 3.0.7

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/README.zh-CN.md +98 -0
  4. package/docs/LEGISLATION-LAW.md +102 -0
  5. package/docs/MASTER-PLAN-2.0.0.md +133 -0
  6. package/docs/MASTER-PLAN-3.0.0.md +76 -0
  7. package/docs/RULES.md +416 -0
  8. package/docs/deepseek-pricing-audit.md +63 -0
  9. package/hooks/guard.mjs +1142 -0
  10. package/hooks/hooks.json +76 -0
  11. package/package.json +61 -4
  12. package/skills/focus-thinking/SKILL.md +299 -0
  13. package/src/adapters/dsh/eligibility-gate.mjs +302 -0
  14. package/src/adapters/dsh/fg-appeal-tool.mjs +47 -0
  15. package/src/adapters/dsh/fg-apply-tool.mjs +68 -0
  16. package/src/adapters/dsh/protocol.mjs +42 -0
  17. package/src/core/approval.mjs +64 -0
  18. package/src/core/audit.mjs +71 -0
  19. package/src/core/backup.mjs +55 -0
  20. package/src/core/checkEligibility.mjs +271 -0
  21. package/src/core/constants.mjs +80 -0
  22. package/src/core/decisionEngine.mjs +45 -0
  23. package/src/core/env.mjs +113 -0
  24. package/src/core/grants.mjs +63 -0
  25. package/src/core/profileLoader.mjs +122 -0
  26. package/src/core/redlines.mjs +238 -0
  27. package/src/core/risk.mjs +75 -0
  28. package/src/core/session.mjs +9 -0
  29. package/src/core/state.mjs +265 -0
  30. package/src/dsh/audit.mjs +192 -0
  31. package/src/dsh/compactionWatch.mjs +407 -0
  32. package/src/dsh/cordis.patch.yml +10 -0
  33. package/src/dsh/heartbeat.mjs +128 -0
  34. package/src/dsh/index.js +146 -0
  35. package/src/dsh/pipeline.mjs +908 -0
  36. package/src/dsh/postProgress.mjs +362 -0
  37. package/src/dsh/preGuard.mjs +288 -0
  38. package/src/dsh/seams.mjs +454 -0
  39. package/src/dsh/selftest.mjs +463 -0
  40. package/src/dsh/sentinel.py +160 -0
  41. package/src/dsh/status.mjs +73 -0
  42. package/src/dsh/stopGuard.mjs +259 -0
  43. package/src/dsh/toolName.mjs +41 -0
  44. package/src/dsh/tools.mjs +115 -0
  45. package/src/dsh/workspace.mjs +145 -0
  46. package/src/peak-cost.mjs +115 -0
  47. package/src/profiles/deepseek-flash.mjs +24 -0
  48. package/src/profiles/deepseek-pro.mjs +25 -0
  49. package/src/profiles/default.mjs +26 -0
  50. package/src/profiles/glm.mjs +26 -0
  51. package/src/profiles/gpt-astra.mjs +30 -0
  52. package/tools/audit-chain.mjs +105 -0
  53. package/tools/env-fingerprint.mjs +88 -0
  54. package/tools/library-build.mjs +222 -0
  55. package/tools/sentinel.mjs +121 -0
@@ -0,0 +1,265 @@
1
+ // FocusGuard 母版层 · 状态与卷宗(v3.0.5;《资料与代码分层自规范》二·1)
2
+ // 会话状态读写与陈旧清扫;卷宗【三】取证记录与 TTL 自适应;文件指纹与 git 脏检。
3
+ import {
4
+ readFileSync, writeFileSync, rmSync, statSync, appendFileSync,
5
+ mkdirSync, existsSync, renameSync, realpathSync, readdirSync, copyFileSync,
6
+ } from "node:fs";
7
+ import { join, dirname, sep, basename, relative } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { createHash } from "node:crypto";
10
+ import { execFileSync } from "node:child_process";
11
+ import { BUDGET_DEFAULT, INV_POOL_DEFAULT, SHA_LIMIT, CASE_MAX_ROWS, TTL_FIRST, TTL_RECENT, TTL_WEEK, TTL_STABLE, DELEGATE_DEFAULT, CASE_TEMPLATE } from "./constants.mjs";
12
+ import { noteFail, audit } from "./audit.mjs";
13
+ import { sid } from "./session.mjs";
14
+
15
+ export function statePath(id) {
16
+ return join(tmpdir(), `focus-guard-${id}.json`);
17
+ }
18
+
19
+ export function loadState(path) {
20
+ try {
21
+ return JSON.parse(readFileSync(path, "utf8"));
22
+ } catch {
23
+ // 2.5.2:文件在但解析失败 = 熔断/已否决命令/审批标记全部清零,属"状态层面的假留痕",
24
+ // 必须让人类看见(与 2.5.1 的留痕防线同源)。首次运行无文件属正常,不告警。
25
+ if (existsSync(path)) noteFail(sid, "会话状态损坏(已按默认值继续:熔断、已否决命令、审批标记全部丢失)");
26
+ return {
27
+ turnCount: 0,
28
+ fused: false,
29
+ stopBlocked: false,
30
+ dumpCount: 0,
31
+ mercy: false,
32
+ violations: 0,
33
+ forcedInvestigate: false,
34
+ probation: false,
35
+ writeOps: 0,
36
+ readSet: {},
37
+ turnPrompt: "",
38
+ taskBudget: BUDGET_DEFAULT,
39
+ declaredBudget: 0,
40
+ effectiveCalls: 0,
41
+ ineffCalls: 0,
42
+ stalledStreak: 0,
43
+ lastSig: "",
44
+ lastInput: "",
45
+ invCalls: 0,
46
+ invCap: INV_POOL_DEFAULT,
47
+ invWarned: false,
48
+ envCache: null, // 环境检测结果即"已检测"的单一事实源(原 envChecked 布尔只写不读且可能与缓存不一致,2.5.1 删除)
49
+ caseCache: {},
50
+ taskInitial: BUDGET_DEFAULT,
51
+ pollutionFlagged: false,
52
+ goalPush: false, // 2.4.0:目标预授权(仅记录,不解锁执行)
53
+ highRiskOk: false, // 2.4.0:执行级授权(仅当回合人类短指令 y/同意 可设置)
54
+ highRiskCmd: "", // 2.4.0:待批/已批的高危命令原文(显示与审计用,>300 字符截断展示)
55
+ highRiskKey: "", // 2.5.2:待批/已批命令的全量哈希(比对与"已否决"登记用,不受截断影响)
56
+ highRiskDeniedThisTurn: false, // 2.4.0:本回合发生过高危拒绝(收尾须带审批单)
57
+ rejectedCmds: {}, // 2.4.0:被人类 n 否决的命令(彻底阻断)
58
+ scriptFiles: {}, // 2.4.0:写入过的脚本文件 → 内容是否含高危命令(绕行检测)
59
+ delegateBudget: DELEGATE_DEFAULT, // 2.3.0:委托池 granted 上限(只升不降)
60
+ delegateUsed: 0, // 2.3.0:委托池累计消耗
61
+ delegated: false, // 2.3.0:本任务是否已委派过
62
+ kpi: 0, // 2.3.0:委派 KPI 累计分
63
+ kpiLowReported: false, // 2.5.1:KPI 跌破 -10 只提醒一次(回升后再跌破可再次提醒)
64
+ kpiScolded: {}, // 2.3.0:每场景每任务只提醒一次
65
+ kpiDelegatedAwarded: false, // 2.3.0:+5 每任务一次
66
+ editedFiles: {}, // 2.3.0:本任务改过的文件集合(批量场景判定)
67
+ };
68
+ }
69
+ }
70
+
71
+ export function saveState(path, state) {
72
+ // 2.5.2 原子落盘:先写同目录临时文件再 rename 覆盖。原直接 writeFileSync 在进程被中断时
73
+ // 会留下半截 JSON(loadState 只能静默降级为默认值,等于整个会话状态凭空消失)。
74
+ const tmp = path + "." + process.pid + ".tmp";
75
+ try {
76
+ writeFileSync(tmp, JSON.stringify(state));
77
+ renameSync(tmp, path);
78
+ } catch {
79
+ try {
80
+ rmSync(tmp, { force: true });
81
+ } catch {}
82
+ noteFail(sid, "会话状态落盘(本次状态未保存,熔断/预算计数可能回退)");
83
+ }
84
+ }
85
+
86
+ // 2.5.3(61条落地):清理系统临时目录中 30 天未动的 focus-guard 状态/回退档案。
87
+ // 实测残留曾达 8376 个(测试与运行残留无限累积);30 天未触碰即视为陈旧,会话启动时清扫。
88
+ export function cleanStaleTemp(sid) {
89
+ try {
90
+ const cutoff = Date.now() - 30 * 86400e3;
91
+ let cleaned = 0;
92
+ for (const f of readdirSync(tmpdir())) {
93
+ if (!/^focus-guard-.*(\.json|-AUDIT\.log)$/.test(f)) continue;
94
+ const fp = join(tmpdir(), f);
95
+ try {
96
+ if (statSync(fp).mtimeMs < cutoff) {
97
+ rmSync(fp, { force: true });
98
+ cleaned++;
99
+ }
100
+ } catch {}
101
+ }
102
+ if (cleaned) audit(sid, "stale-cleaned", { level: null, evidence: `61条 清理 ${cleaned} 个 30 天未动的临时状态/档案文件` });
103
+ } catch {}
104
+ }
105
+
106
+ export function normalize(p) {
107
+ return String(p || "").replace(/\\/g, "/");
108
+ }
109
+
110
+ // ============ 2.0 环境检测(总纲三:会话级一次,全程复用) ============
111
+
112
+ export function casePath(projDir) {
113
+ return join(projDir, ".ai", "CASE_FILE.md");
114
+ }
115
+
116
+ export function ensureCaseFile(projDir) {
117
+ const p = casePath(projDir);
118
+ if (!existsSync(p)) {
119
+ mkdirSync(dirname(p), { recursive: true });
120
+ writeFileSync(p, CASE_TEMPLATE);
121
+ }
122
+ return p;
123
+ }
124
+
125
+ export function sectionOf(text, marker) {
126
+ const re = new RegExp("### " + marker + "[\\s\\S]*?(?=\\n### |\\n## |$)");
127
+ return (text.match(re) || [""])[0];
128
+ }
129
+
130
+ export function parseDur(s) {
131
+ const m = String(s || "").trim().match(/^(\d+(?:\.\d+)?)\s*(分钟|小时|天|h|d|H|D)?$/);
132
+ if (!m) return null;
133
+ const n = parseFloat(m[1]);
134
+ const u = m[2] || "小时";
135
+ if (u === "分钟") return n * 60e3;
136
+ if (u === "天" || u === "d" || u === "D") return n * 86400e3;
137
+ return n * 3600e3;
138
+ }
139
+
140
+ export function loadCaseRecords(p) {
141
+ const out = {};
142
+ try {
143
+ const t = readFileSync(p, "utf8");
144
+ for (const line of sectionOf(t, "【三】").split("\n")) {
145
+ if (!line.startsWith("|")) continue;
146
+ const c = line.split("|").map((x) => x.trim());
147
+ if (c.length < 9) continue;
148
+ const [, f, readAt, mtime, size, sha, hist, ttl, via] = c;
149
+ if (!f || f === "文件名" || /^-+$/.test(f)) continue;
150
+ const hm = String(hist || "").match(/n=(\d+);?\s*last=(\S+)/);
151
+ out[normalize(f)] = {
152
+ path: f,
153
+ readAt: Date.parse(readAt) || 0,
154
+ mtime: parseFloat(mtime) || 0,
155
+ size: parseInt(size, 10) || 0,
156
+ sha: sha && sha !== "-" ? sha : "",
157
+ changes: hm ? parseInt(hm[1], 10) || 0 : 0,
158
+ lastChange: hm && hm[2] && hm[2] !== "-" ? Date.parse(hm[2]) || 0 : 0,
159
+ ttlOverride: ttl || "",
160
+ via: via || "mtime+size",
161
+ };
162
+ }
163
+ } catch {}
164
+ return out;
165
+ }
166
+
167
+ export function saveCaseRecords(projDir, records) {
168
+ let tmp = null;
169
+ try {
170
+ const p = ensureCaseFile(projDir);
171
+ tmp = p + "." + process.pid + ".tmp";
172
+ let t = readFileSync(p, "utf8");
173
+ const rows = Object.values(records)
174
+ .sort((a, b) => (b.readAt || 0) - (a.readAt || 0))
175
+ .slice(0, CASE_MAX_ROWS);
176
+ const table = ["| 文件名 | 读取时间 | mtime | size | SHA-256 | 变更历史 | TTL | 验证方式 |", "|---|---|---|---|---|---|---|---|"]
177
+ .concat(
178
+ rows.map(
179
+ (r) =>
180
+ `| ${r.path} | ${new Date(r.readAt || Date.now()).toISOString()} | ${r.mtime} | ${r.size} | ${r.sha || "-"} | n=${r.changes || 0}; last=${r.lastChange ? new Date(r.lastChange).toISOString() : "-"} | ${r.ttlOverride || ""} | ${r.via || "mtime+size"} |`
181
+ )
182
+ )
183
+ .join("\n");
184
+ t = t.replace(/(### 【三】[\s\S]*?\n)\| 文件名 \|[\s\S]*?(?=\n### |\n## |$)/, (_m, head) => head + table + "\n");
185
+ writeFileSync(tmp, t);
186
+ renameSync(tmp, p);
187
+ } catch {
188
+ // FG-D3:rename 失败清理 .tmp 残片(对齐 saveState 防线,防 .ai/ 积累孤片)
189
+ try { if (tmp) rmSync(tmp, { force: true }); } catch {}
190
+ noteFail(sid, "卷宗【三】侦查记录");
191
+ }
192
+ }
193
+
194
+ export function saveLedger(projDir, state) {
195
+ let tmp = null;
196
+ try {
197
+ const p = ensureCaseFile(projDir);
198
+ tmp = p + "." + process.pid + ".tmp";
199
+ let t = readFileSync(p, "utf8");
200
+ const eff = state.effectiveCalls || 0;
201
+ const inv = state.invCalls || 0;
202
+ const used = eff + inv;
203
+ const row = `| ${new Date().toISOString().slice(0, 16)} | ${state.taskInitial ?? state.taskBudget ?? BUDGET_DEFAULT} | ${used} | ${Math.max(0, (state.taskBudget || BUDGET_DEFAULT) - used)} | ${eff} | ${state.ineffCalls || 0} | ${new Date().toISOString()} | KPI ${state.kpi || 0} |`;
204
+ const table = ["| 任务 | 初始额度 | 已用额度 | 剩余额度 | 有效调用 | 无效调用 | 更新时间 | KPI |", "|---|---|---|---|---|---|---|---|", row].join("\n");
205
+ t = t.replace(/### 【四】[\s\S]*?(?=\n### |\n## |$)/, () => "### 【四】工作额度台账\n\n" + table + "\n");
206
+ writeFileSync(tmp, t);
207
+ renameSync(tmp, p);
208
+ } catch {
209
+ // FG-D3:同上
210
+ try { if (tmp) rmSync(tmp, { force: true }); } catch {}
211
+ noteFail(sid, "卷宗【四】额度台账");
212
+ }
213
+ }
214
+
215
+ export function fingerprint(absPath) {
216
+ const st = statSync(absPath);
217
+ const fp = { mtime: st.mtimeMs, size: st.size, sha: "" };
218
+ if (st.size <= SHA_LIMIT) fp.sha = createHash("sha256").update(readFileSync(absPath)).digest("hex").slice(0, 16);
219
+ return fp;
220
+ }
221
+
222
+ export function gitDirty(projDir, absPath) {
223
+ try {
224
+ if (!projDir) return null;
225
+ const rel = normalize(absPath).replace(normalize(projDir) + "/", "");
226
+ const out = execFileSync("git", ["-C", projDir, "status", "--porcelain", "--", rel], {
227
+ encoding: "utf8",
228
+ timeout: 4000,
229
+ stdio: ["ignore", "pipe", "ignore"],
230
+ });
231
+ return out.trim() ? 1 : 0;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ // 总纲五:优先级 依赖声明 > 人工标注 > 自适应
238
+ export function resolveTTL(projDir, rec, filePath) {
239
+ if (projDir) {
240
+ try {
241
+ const t = readFileSync(casePath(projDir), "utf8");
242
+ for (const line of sectionOf(t, "【二】").split("\n")) {
243
+ if (!line.startsWith("|")) continue;
244
+ const c = line.split("|").map((x) => x.trim());
245
+ if (c.length < 7) continue;
246
+ const [, name, , ipath, , ttl] = c;
247
+ const d = parseDur(ttl);
248
+ if (!d || !ipath || ipath === "安装路径") continue;
249
+ if (filePath.startsWith(normalize(ipath))) return { ms: d, src: `依赖声明:${name}` };
250
+ }
251
+ } catch {}
252
+ }
253
+ if (rec && rec.ttlOverride) {
254
+ const d = parseDur(rec.ttlOverride);
255
+ if (d) return { ms: d, src: "人工标注" };
256
+ }
257
+ if (!rec || !rec.lastChange) return { ms: TTL_FIRST, src: "自适应:首次4h" };
258
+ const age = Date.now() - rec.lastChange;
259
+ if (age >= 30 * 86400e3) return { ms: TTL_STABLE, src: "自适应:30天未变" };
260
+ if (age >= 7 * 86400e3) return { ms: TTL_WEEK, src: "自适应:7-30天未变" };
261
+ return { ms: TTL_RECENT, src: "自适应:7天内有变" };
262
+ }
263
+
264
+ // ============ 2.0 平台命令规则(总纲六:按检出 shell 适配) ============
265
+
@@ -0,0 +1,192 @@
1
+ /**
2
+ * focus-guard 原生插件 · 审计接入(3.0.5)
3
+ *
4
+ * 卷宗写入口:AUDIT.log(拦截留痕)与 CASE_FILE.md(成本台账)。
5
+ * 一切写入独立 try/catch:留痕失败只 warn,绝不阻断宿主主流程;
6
+ * 也绝不因写失败把拦截降级成放行(见 pipeline.mjs 调用次序)。
7
+ */
8
+ import { appendFileSync, readFileSync, statSync, renameSync, mkdirSync, rmSync, readdirSync, existsSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { join, dirname, basename } from 'node:path'
11
+ import { calcCost } from '../peak-cost.mjs'
12
+
13
+ // 插件文件位于 <仓库根>/packages/core/src/dsh/ → 上溯 4 级到仓库根
14
+ export const AUDIT_FILE = fileURLToPath(new URL('../../../../.focus-guard/AUDIT.log', import.meta.url))
15
+
16
+ /** 审计日志体积上限(字节);`FG_AUDIT_MAX_BYTES` 可覆盖。默认 5 MB ≈ 2 万条记录。 */
17
+ export const AUDIT_MAX_BYTES = Number(process.env.FG_AUDIT_MAX_BYTES) || 5 * 1024 * 1024
18
+
19
+ /** 归档保留份数;超出则删最旧。`FG_AUDIT_ARCHIVE_KEEP` 可覆盖。 */
20
+ export const AUDIT_ARCHIVE_KEEP = Number(process.env.FG_AUDIT_ARCHIVE_KEEP) || 10
21
+
22
+ /**
23
+ * 当前审计目标路径。
24
+ * `FG_AUDIT_FILE` 可重定向——**测试必须**用它指向 tmpdir,否则会写进真实工作区
25
+ *(2026-10-09 事故:污染 2630 行 / 746 个测试 session)。与 `caseFilePath` 的 `FG_CASE_FILE` 同思路。
26
+ */
27
+ export function auditFilePath() {
28
+ return process.env.FG_AUDIT_FILE || AUDIT_FILE
29
+ }
30
+
31
+ /**
32
+ * 体积轮转(2026-10-09):超出上限则把当前日志移入同目录 `archive/`,并立刻重建空文件。
33
+ * 纪律三条:
34
+ * ① 只归档、不删除(执法记录不得丢)——超保留份数才清理最旧存档;
35
+ * ② 任何失败只 warn,绝不阻断写入方(与全文件 fail-open 口径一致);
36
+ * ③ 轮转失败时**继续追加原文件**——宁可文件变大,不可丢记录。
37
+ * 注意:封存的 `hooks/guard.mjs`(ZCode 层)不经此处写入,它的日志不受本机制管辖。
38
+ * @returns {string|null} 归档目标路径;未轮转或失败返回 null
39
+ */
40
+ export function rotateAuditIfNeeded(file = auditFilePath(), max = AUDIT_MAX_BYTES) {
41
+ try {
42
+ // 文件尚不存在 = 首次写入,属正常情况,静默放过。
43
+ // (不先判存在会走 statSync 抛 ENOENT,每次首写都刷一条"轮转失败"warn,噪音掩盖真问题。)
44
+ if (!existsSync(file)) return null
45
+ const size = statSync(file).size
46
+ if (size < max) return null
47
+ const dir = join(dirname(file), 'archive')
48
+ mkdirSync(dir, { recursive: true })
49
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
50
+ const dest = join(dir, `${basename(file)}.${stamp}`)
51
+ renameSync(file, dest)
52
+ appendFileSync(file, '') // 立刻重建空文件,保持 append-only 语义
53
+ const prefix = `${basename(file)}.`
54
+ const olds = readdirSync(dir)
55
+ .filter((n) => n.startsWith(prefix))
56
+ .sort()
57
+ for (const n of olds.slice(0, Math.max(0, olds.length - AUDIT_ARCHIVE_KEEP))) {
58
+ try {
59
+ rmSync(join(dir, n), { force: true })
60
+ } catch {
61
+ /* 清理旧存档失败可忽略,不影响本次轮转 */
62
+ }
63
+ }
64
+ console.warn(`[focus-guard-native] 审计日志已轮转:${dest}(原 ${size} 字节)`)
65
+ return dest
66
+ } catch (error) {
67
+ console.warn(
68
+ '[focus-guard-native] 审计轮转失败(继续追加原文件,不丢记录):',
69
+ (error && error.message) || error,
70
+ )
71
+ return null
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 统一审计写入入口(3.0.7):三处写点(auditDeny / auditRedlineExempt / auditEligibility)共用,
77
+ * 避免"同一文件两种口径"复发(2026-10-09 事故的根因之一),并让轮转只在一处生效。
78
+ * @returns {boolean} 是否写入成功
79
+ */
80
+ export function appendAudit(row, file = auditFilePath()) {
81
+ try {
82
+ rotateAuditIfNeeded(file)
83
+ // 2026-10-18:先确保目录存在再追加。原先若 `.focus-guard/` 尚未创建,
84
+ // 首写会 ENOENT 被 catch 吞成一条 warn —— 而 FG 现在**第一条**写入是启动自证
85
+ // `compaction-watch-ready`(进程一启动就写,早于任何会话活动)。它要是丢了,
86
+ // "自证清白"就成了自证不了。mkdir 失败仍在同一 try 内,降级口径不变。
87
+ mkdirSync(dirname(file), { recursive: true })
88
+ appendFileSync(file, JSON.stringify(row) + '\n')
89
+ return true
90
+ } catch (error) {
91
+ console.warn('[focus-guard-native] AUDIT 留痕失败(不阻断):', (error && error.message) || error)
92
+ return false
93
+ }
94
+ }
95
+
96
+ /** 从 exec 取会话标识(三处写入共用的口径)。 */
97
+ function sessionOf(exec) {
98
+ // ⚠ 取值口径必须与官方桥一致(lib/index.js:349-354)——真实会话 id 在 agent.session.header.id。
99
+ // 2026-10-10 实测:只认 exec.sessionId / agent.sessionId / agent.id 会全部取空,
100
+ // 于是所有会话退化成同一个 'dsh-native'(真实 AUDIT.log 里 439 条记录都记在 dsh-native 名下,
101
+ // 而取值正确的 seams 写的是 sess_<uuid>)——这会造成跨会话状态与审计互相污染。
102
+ return (
103
+ (exec &&
104
+ ((exec.agent && exec.agent.session && exec.agent.session.header && exec.agent.session.header.id) ||
105
+ exec.sessionId ||
106
+ (exec.agent && (exec.agent.sessionId || exec.agent.id)))) ||
107
+ 'dsh-native'
108
+ )
109
+ }
110
+
111
+ /**
112
+ * 拦截写入一条审计记录(JSONL 追加,字段与 hooks/guard.mjs 口径一致)。
113
+ * 2026-10-18 修(REPORT-2026-10-12 P2):trigger/level 原为常量 'high-risk-rm'/3,
114
+ * 与真实拦截规则无关 —— `ls -la` 也被记成 "high-risk-rm",审计无法回答"为什么被拦"。
115
+ * 现由调用点传 detail.trigger(规则名),缺省才退回 'unspecified'。
116
+ * @param {unknown} exec
117
+ * @param {unknown} cmd
118
+ * @param {{ trigger?: string, level?: number|null }} [detail]
119
+ */
120
+ export function auditDeny(exec, cmd, detail = {}) {
121
+ return appendAudit({
122
+ ts: new Date().toISOString(),
123
+ session: sessionOf(exec),
124
+ action: 'deny',
125
+ trigger: detail.trigger || 'unspecified',
126
+ level: detail.level ?? 3,
127
+ evidence: String(cmd).slice(0, 120),
128
+ pardon: false,
129
+ })
130
+ }
131
+
132
+ /**
133
+ * 红线上下文豁免留痕(3.0.6 P0,HANDOFF §八)。
134
+ * 命中绝对红线但被判为"被引用的命令字符串"而豁免时调用——豁免不是静默放行,必须可审计。
135
+ */
136
+ export function auditRedlineExempt(exec, cmd, detail = {}) {
137
+ return appendAudit({
138
+ ts: new Date().toISOString(),
139
+ session: sessionOf(exec),
140
+ action: 'redline-exempt',
141
+ trigger: detail.redline || 'absolute-redline',
142
+ level: null,
143
+ basis: detail.basis || null,
144
+ evidence: `${detail.detail || ''} | ${String(cmd).slice(0, 120)}`.slice(0, 300),
145
+ pardon: false,
146
+ })
147
+ }
148
+
149
+ /**
150
+ * 成本台账落卷目标。
151
+ * 默认 = 仓库根 `.ai/CASE_FILE.md`;`FG_CASE_FILE` 可覆盖——测试**必须**用它重定向到临时目录,
152
+ * 否则自检会写进人类真实卷宗(2026-10-07 事故)。落盘前另有一道守卫,见 appendCostRow。
153
+ */
154
+ export function caseFilePath() {
155
+ return process.env.FG_CASE_FILE || fileURLToPath(new URL('../../../../.ai/CASE_FILE.md', import.meta.url))
156
+ }
157
+
158
+ const COST_MARK = '【五】成本台账'
159
+
160
+ /** 成本台账落卷:卷宗无该章节则先补表头,再逐行追加;失败只 warn 不阻塞 */
161
+ export function appendCostRow(ts, model, inPeak, usage) {
162
+ try {
163
+ let header = ''
164
+ try {
165
+ if (!readFileSync(caseFilePath(), 'utf8').includes(COST_MARK)) {
166
+ header =
167
+ '\n### 【五】成本台账(DSH 原生插件自动追加;tokens / USD)\n\n' +
168
+ '| 时间 | 模型 | 时段 | 输入未缓存 | 缓存读 | 输出 | 成本USD |\n' +
169
+ '|---|---|---|---|---|---|---|\n'
170
+ }
171
+ } catch (error) {
172
+ console.warn(
173
+ '[focus-guard-native] 读卷宗失败,按无表头处理(写入仍尝试):',
174
+ (error && error.message) || error,
175
+ )
176
+ }
177
+ const usd = calcCost(model, usage, inPeak)
178
+ const target = caseFilePath()
179
+ // 写守卫:目标必须是默认卷宗路径,或调用方显式用 FG_CASE_FILE 重定向(测试用)。
180
+ // 其余一律拒写——防止任何代码路径把台账写到意料之外的位置。
181
+ const DEFAULT_CASE_FILE = fileURLToPath(new URL('../../../../.ai/CASE_FILE.md', import.meta.url))
182
+ if (target !== DEFAULT_CASE_FILE && !process.env.FG_CASE_FILE) {
183
+ throw new Error(`成本台账目标异常,拒绝写入:${target}`)
184
+ }
185
+ appendFileSync(
186
+ target,
187
+ `${header}| ${ts} | ${model} | ${inPeak ? '峰' : '谷'} | ${usage.input} | ${usage.cacheRead} | ${usage.output} | ${usd === null ? '未知价目' : usd.toFixed(6)} |\n`,
188
+ )
189
+ } catch (error) {
190
+ console.warn('[focus-guard-native] 成本台账写入失败(不阻塞):', (error && error.message) || error)
191
+ }
192
+ }