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,407 @@
1
+ /**
2
+ * focus-guard 原生插件 · 压缩监察(2026-10-18)
3
+ *
4
+ * ── 为什么有这一层,而不是"再写一个压缩引擎" ────────────────────────────
5
+ * DSH 0.2 **自带**完整压缩栈,且当前会话就在用:
6
+ * dsh-compaction(抽象缝 ctx.compaction)
7
+ * + dsh-compaction-basic(后端,自动触发)
8
+ * + dsh-command-compact(/compact)
9
+ * + dsh-compaction-tool-result-pruner(超长工具结果剪枝)
10
+ * 挂载点在 preset 层:dsh-web-app/presets/cordis.patch.yml 的 cordis preset,
11
+ * host 平面则由 dsh-web-app/cordis.patch.yml 显式 disabled(压缩归 preset 所有)。
12
+ *
13
+ * 曾经存在的外部插件 DCR(local-dsh-memory-v09-live-extract)自建了一套压缩,
14
+ * 在 agent/pre-step 上 `agent.cancel` + `{kind:'reject'}`,抢在内置监听器之前把回合
15
+ * 打死在 step 0 —— 这才是 2026-10-18 那串 `CONTEXT_COMPACTION_REQUIRED` 的真根因。
16
+ * 内置阈值约 49k tokens 就触发,DCR 的 HARD 在 98k,内置本可早压。
17
+ *
18
+ * 结论:压缩由 DSH 负责,**FG 只负责监察**。
19
+ * 本模块的三条纪律:
20
+ * ① 只读事件,绝不写 surface、绝不 append、绝不 cancel —— 它没有能力改变会话;
21
+ * ② 只用事件里已经带着的事实(compactionId / shadowedSeqs / shadowedTokenCount /
22
+ * usage.inputTokens / failure.code),**不猜模型上下文窗口**,不设自造阈值;
23
+ * ③ 全部观察包在 try/catch 里,留痕失败只 warn —— 监察不能成为宿主的新故障点。
24
+ *
25
+ * ── 留痕口径 ───────────────────────────────────────────────────────────
26
+ * 走 audit.mjs 的统一入口 `appendAudit`(JSONL,与 deny / redline-exempt 同文件同格式)。
27
+ * 新增三个 action,纯增量,不改变既有记录的语义:
28
+ * compaction 一次压缩落定(result: ok | failed)
29
+ * compaction-anomaly 标记对异常(未闭合的 start / 无主的 end / id 不匹配)
30
+ * context-overflow 请求撞上下文窗口 —— 这是"该压却没压"的唯一硬证据
31
+ * compaction-watch-ready / compaction-watch-degraded
32
+ * 进程级启动自证:本层是否真的挂上了两条缝。
33
+ * 没有它,AUDIT.log 无法独立回答"监察层在不在"——
34
+ * 只能靠 status.json 侧面推断,而 status.json 是心跳文件、不是审计台账。
35
+ * **同一状态每进程只报一次**;降级↔就位发生翻转时会再报一次(那是真实状态变化)。
36
+ *
37
+ * `compaction/prune`(工具结果剪枝)**不逐条留痕**:它按 8192 字符阈值频繁触发,
38
+ * 逐条写会把审计刷成流水账。改为只累计计数与释放 token,随 compaction / context-overflow
39
+ * 记录一并带出(prunes_so_far / pruned_tokens),既不丢信息也不淹没有效记录。
40
+ */
41
+
42
+ import { appendAudit } from './audit.mjs'
43
+ import { moduleSnapshot } from './heartbeat.mjs'
44
+
45
+ const EV_START = 'compaction/start'
46
+ const EV_END = 'compaction/end'
47
+ const EV_PRUNE = 'compaction/prune'
48
+ const EV_USER = 'user/message'
49
+ const EV_ASSISTANT = 'assistant/message'
50
+
51
+ /** 撞窗错误码,取自 @deepseek-ai/dsh-llm(lib/index.js:136 与 lib/types/error.js:22 同值)。 */
52
+ export const CONTEXT_OVERFLOW_CODE = 'CONTEXT_WINDOW_EXCEEDED'
53
+
54
+ /** 每会话监察状态。WeakMap 以 session 对象为键,会话释放即回收,不泄漏。 */
55
+ const states = new WeakMap()
56
+
57
+ /** 会话标识口径与 audit.mjs::sessionOf 一致:真实 id 在 session.id / session.header.id。 */
58
+ function sessionIdOf(session) {
59
+ try {
60
+ const id = session?.id ?? session?.header?.id
61
+ if (typeof id === 'string' && id) return id
62
+ } catch {
63
+ /* header 是惰性 getter,取失败不致命 */
64
+ }
65
+ return 'dsh-native'
66
+ }
67
+
68
+ function stateFor(session) {
69
+ let state = states.get(session)
70
+ if (!state) {
71
+ state = {
72
+ sessionId: sessionIdOf(session),
73
+ open: null, // 未闭合的 compaction/start
74
+ compactions: 0,
75
+ failed: 0,
76
+ anomalies: 0,
77
+ prunes: 0,
78
+ prunedTokens: 0,
79
+ lastInputTokens: 0,
80
+ summary: null, // 本次压缩写下的替换消息(surfaceOp: replace)
81
+ overflowWarned: false,
82
+ }
83
+ states.set(session, state)
84
+ }
85
+ return state
86
+ }
87
+
88
+ /** 供自检 / 测试读取,不参与生产路径。 */
89
+ export function _compactionWatchState(session) {
90
+ const state = states.get(session)
91
+ return state ? { ...state, open: state.open ? { ...state.open } : null, summary: state.summary ? { ...state.summary } : null } : null
92
+ }
93
+
94
+ function row(state, action, trigger, extra = {}) {
95
+ return {
96
+ ts: new Date().toISOString(),
97
+ session: state.sessionId,
98
+ action,
99
+ trigger,
100
+ level: null,
101
+ basis: 'builtin:compaction-basic',
102
+ ...extra,
103
+ pardon: false,
104
+ }
105
+ }
106
+
107
+ function clampText(value, max = 300) {
108
+ const text = typeof value === 'string' ? value : String(value ?? '')
109
+ return text.length > max ? text.slice(0, max) + '…' : text
110
+ }
111
+
112
+ /** 压缩落定留痕。 */
113
+ function auditCompaction(state, { failed, durationMs, open, summary, error }) {
114
+ const parts = [
115
+ failed ? 'failed' : 'ok',
116
+ summary ? `${summary.shadowedNodes} 节点 seq ${summary.startSeq}..${summary.endSeq}` : '未观测到替换消息',
117
+ `${durationMs}ms`,
118
+ `输入 ${state.lastInputTokens}`,
119
+ ]
120
+ if (failed) parts.push(`error=${clampText(error, 120)}`)
121
+ appendAudit(
122
+ row(state, 'compaction', failed ? 'compaction/end(error)' : 'compaction/end', {
123
+ basis: open.sourceCommandId ? 'builtin:command-compact' : 'builtin:compaction-basic',
124
+ result: failed ? 'failed' : 'ok',
125
+ compaction_id: open.compactionId,
126
+ turn: open.turn,
127
+ source_command_id: open.sourceCommandId,
128
+ duration_ms: durationMs,
129
+ shadowed_nodes: summary ? summary.shadowedNodes : null,
130
+ shadowed_range: summary ? `${summary.startSeq}..${summary.endSeq}` : null,
131
+ input_tokens: state.lastInputTokens || null,
132
+ // 剪枝累计:不逐条留痕,但要让它随压缩记录一起可见("释放了多少")
133
+ prunes_so_far: state.prunes,
134
+ pruned_tokens: state.prunedTokens,
135
+ error: failed ? clampText(error, 300) : null,
136
+ evidence: clampText(parts.join(' | ')),
137
+ }),
138
+ )
139
+ }
140
+
141
+ /** 标记对异常留痕。0.2 用"未匹配的 start"作为 busy 信号,异常本身就是可诊断事实。 */
142
+ function auditAnomaly(state, kind, detail) {
143
+ state.anomalies += 1
144
+ appendAudit(
145
+ row(state, 'compaction-anomaly', kind, {
146
+ detail: clampText(detail, 300),
147
+ evidence: clampText(`${kind}: ${detail}`),
148
+ }),
149
+ )
150
+ }
151
+
152
+ /**
153
+ * 观察一条会话事件。
154
+ * 纯函数式副作用:只更新本会话的监察状态并留痕,永不抛、永不改会话。
155
+ */
156
+ function observe(session, event) {
157
+ if (!event || typeof event !== 'object') return
158
+ const type = event.type
159
+ if (type !== EV_START && type !== EV_END && type !== EV_PRUNE && type !== EV_USER && type !== EV_ASSISTANT) return
160
+
161
+ const state = stateFor(session)
162
+ const data = event.data && typeof event.data === 'object' ? event.data : {}
163
+
164
+ if (type === EV_START) {
165
+ if (state.open) {
166
+ auditAnomaly(
167
+ state,
168
+ 'unmatched-start',
169
+ `上一次 start(id=${state.open.compactionId ?? '-'} seq=${state.open.seq})未闭合就开始了 id=${data.compactionId ?? '-'}`,
170
+ )
171
+ }
172
+ state.open = {
173
+ compactionId: data.compactionId ?? null,
174
+ turn: data.turn ?? null,
175
+ // 手动 /compact 会带 sourceCommandId(见 dsh-compaction-basic compactSurfaceRegion),
176
+ // 据此区分"自动压缩"与"人类按需压缩",审计里可分辨是谁触发的。
177
+ sourceCommandId: data.sourceCommandId ?? null,
178
+ seq: Number(event.seq) || 0,
179
+ at: Number(event.time) || Date.now(),
180
+ }
181
+ state.summary = null
182
+ return
183
+ }
184
+
185
+ if (type === EV_USER) {
186
+ // 压缩的摘要以 `user/message` + surfaceOp{op:'replace', startSeq, endSeq} 落在标记对之间。
187
+ const op = event.surfaceOp
188
+ if (state.open && op && typeof op === 'object' && op.op === 'replace') {
189
+ const sources = Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs : []
190
+ state.summary = {
191
+ startSeq: op.startSeq ?? null,
192
+ endSeq: op.endSeq ?? null,
193
+ shadowedNodes: sources.length,
194
+ }
195
+ }
196
+ return
197
+ }
198
+
199
+ if (type === EV_END) {
200
+ const open = state.open
201
+ state.open = null
202
+ if (!open) {
203
+ auditAnomaly(state, 'orphan-end', `compaction/end(id=${data.compactionId ?? '-'})没有对应的 start`)
204
+ state.summary = null
205
+ return
206
+ }
207
+ if (open.compactionId && data.compactionId && open.compactionId !== data.compactionId) {
208
+ auditAnomaly(state, 'mismatched-id', `start id=${open.compactionId} 对上了 end id=${data.compactionId}`)
209
+ }
210
+ const failed = data.error !== undefined
211
+ if (failed) state.failed += 1
212
+ else state.compactions += 1
213
+ auditCompaction(state, {
214
+ failed,
215
+ durationMs: Math.max(0, (Number(event.time) || Date.now()) - open.at),
216
+ open,
217
+ summary: state.summary,
218
+ error: data.error,
219
+ })
220
+ state.summary = null
221
+ return
222
+ }
223
+
224
+ if (type === EV_PRUNE) {
225
+ state.prunes += 1
226
+ state.prunedTokens += Number(data.shadowedTokenCount) || 0
227
+ return
228
+ }
229
+
230
+ // EV_ASSISTANT:记录本次请求的实际输入规模(供应商回报,非估算)
231
+ const input = Number(data?.usage?.inputTokens)
232
+ if (Number.isFinite(input) && input > 0) state.lastInputTokens = input
233
+ }
234
+
235
+ /**
236
+ * `session/event` 监听器工厂(普通事件,非 waterfall)。
237
+ * @param {{ warn: Function }} ctx 由 index.js 的 register() 注入
238
+ */
239
+ export function compactionEventListener({ warn } = {}) {
240
+ // warn 缺失时退化为静默:catch 块里再抛一次,等于把监察变成新的 turn 杀手。
241
+ const emit = typeof warn === 'function' ? warn : () => {}
242
+ return (session, event) => {
243
+ try {
244
+ observe(session, event)
245
+ } catch (error) {
246
+ emit('压缩监察观察失败(不阻断会话):', (error && error.message) || error)
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * `agent/request-error` 监听器工厂(**waterfall,必须始终 return next()**)。
253
+ *
254
+ * 撞窗是"该压却没压"的唯一硬证据——它由供应商拒绝给出,不需要 FG 猜任何阈值。
255
+ * 若此时本会话一次压缩、一次剪枝都没发生过,说明压缩栈根本没在干活,必须喊出来。
256
+ * @param {{ warn: Function }} ctx
257
+ */
258
+ export function contextOverflowListener({ warn } = {}) {
259
+ const emit = typeof warn === 'function' ? warn : () => {}
260
+ return async ({ agent, failure }, next) => {
261
+ try {
262
+ if (failure && failure.code === CONTEXT_OVERFLOW_CODE) {
263
+ const session = agent && agent.session
264
+ if (session) {
265
+ const state = stateFor(session)
266
+ const dead = state.compactions === 0 && state.prunes === 0
267
+ appendAudit(
268
+ row(state, 'context-overflow', 'agent/request-error', {
269
+ code: failure.code,
270
+ input_tokens: state.lastInputTokens || null,
271
+ compactions_so_far: state.compactions,
272
+ failed_so_far: state.failed,
273
+ prunes_so_far: state.prunes,
274
+ pruned_tokens: state.prunedTokens,
275
+ stack_looks_dead: dead,
276
+ error: clampText(failure.message ?? failure.code, 300),
277
+ evidence: clampText(
278
+ `撞窗 code=${failure.code} 输入=${state.lastInputTokens || '-'} ` +
279
+ `压缩=${state.compactions} 失败=${state.failed} 剪枝=${state.prunes}(${state.prunedTokens}tok)` +
280
+ (dead ? ' —— 压缩栈疑似未生效' : ''),
281
+ ),
282
+ }),
283
+ )
284
+ if (dead && !state.overflowWarned) {
285
+ state.overflowWarned = true
286
+ emit(
287
+ `压缩监察:会话 ${state.sessionId} 撞上下文窗口,但本会话 0 次压缩 / 0 次剪枝 —— ` +
288
+ '内置压缩栈疑似未挂载(检查 preset 里的 compaction-basic 行)',
289
+ )
290
+ }
291
+ }
292
+ }
293
+ } catch (error) {
294
+ emit('压缩监察(撞窗)观察失败(不阻断会话):', (error && error.message) || error)
295
+ }
296
+ // waterfall 纪律:无论观察到什么都必须放行,本模块没有拦截权。
297
+ return typeof next === 'function' ? next() : undefined
298
+ }
299
+ }
300
+
301
+ // ── 启动自证(2026-10-18,人类批示「让日志真正做到自证清白」)────────────────
302
+ //
303
+ // 病根:上面所有记录都是**被动**的——只有会话真的压过一次,AUDIT.log 里才有 `compaction`。
304
+ // 于是"监察层没挂上"和"挂上了但还没触发"在日志里长得一模一样,都表现为"什么都没有"。
305
+ // 唯一旁证是 `.focus-guard/status.json`,但那是心跳文件、随时被覆写,而且它证明的是
306
+ // "插件在跳",不是"这两条缝在听"。要把两者分开,只能在进程启动时无条件留一条。
307
+ //
308
+ // 口径:**同一状态每进程只报一次**(`announced` 闸门);ready↔degraded 真发生翻转时再报一次
309
+ // ——那不是重复,是真实的状态变化,必须留痕。翻转在当前装配流程里理论上不会发生
310
+ // (register() 在 apply() 内同步跑完),闸门是防御性的,不是功能。
311
+
312
+ /** 本层依赖的两条宿主缝;label 必须与 index.js 里 register() 的第三参数逐字一致。 */
313
+ export const WATCH_SEAMS = ['session/event(compaction-watch)', 'agent/request-error(compaction-watch)']
314
+
315
+ /** 进程启动时刻:只用于让"这条记录属于哪一次启动"可核对,不参与任何判断。 */
316
+ const PROCESS_STARTED_AT = new Date().toISOString()
317
+
318
+ /** @type {'ready'|'degraded'|null} 上一次宣告的状态;null = 本进程尚未宣告。 */
319
+ let announced = null
320
+
321
+ /** 测试钩子:清空宣告闸门,使同一进程内可重复验证。生产路径不调用。 */
322
+ export function _resetWatchAnnounce() {
323
+ announced = null
324
+ }
325
+
326
+ /**
327
+ * 读当前装配快照,判定本层是否真的挂上了两条缝。
328
+ * 数据源是 heartbeat.mjs 的 MODULES —— register() 成功即 markModule(label, true),
329
+ * 这是"注册到底成没成"的唯一权威口径,比任何自检代码更可信。
330
+ * @returns {{ ready: boolean, seams: Record<string,string>, bad: string[], ok: number, total: number, degraded: string[] }}
331
+ */
332
+ function seamSnapshot() {
333
+ const snapshot = moduleSnapshot()
334
+ const seams = {}
335
+ const bad = []
336
+ for (const key of WATCH_SEAMS) {
337
+ // 未出现在快照里 = apply() 根本没跑到那行 register(),与 FAIL 同等对待。
338
+ const value = snapshot[key] === undefined ? 'MISSING: 未报到' : snapshot[key]
339
+ seams[key] = value
340
+ if (value !== 'OK') bad.push(`${key}=${value}`)
341
+ }
342
+ const keys = Object.keys(snapshot)
343
+ return {
344
+ ready: bad.length === 0,
345
+ seams,
346
+ bad,
347
+ ok: keys.filter((k) => snapshot[k] === 'OK').length,
348
+ total: keys.length,
349
+ // 全量降级清单:自证行要能独立说明"整机几成",而不只是本层两条缝。
350
+ degraded: keys.filter((k) => snapshot[k] !== 'OK'),
351
+ }
352
+ }
353
+
354
+ /**
355
+ * 启动自证:把"监察层在不在"无条件写进 AUDIT.log 一次。
356
+ *
357
+ * 必须在 index.js 的两条 register() **与**心跳启动**之后**调用 ——
358
+ * 自证要反映的是最终装配结果,早一步调用就会漏掉心跳、报出错误的完成度。
359
+ *
360
+ * 永不抛:写不进去只 warn。自证失败不能变成宿主的新故障点(与本层其余部分同纪律)。
361
+ * @param {{ warn?: Function }} [options]
362
+ * @returns {'ready'|'degraded'|null} 本次宣告的状态;被闸门拦下时返回上次的状态
363
+ */
364
+ export function announceWatchReady({ warn } = {}) {
365
+ const emit = typeof warn === 'function' ? warn : () => {}
366
+ try {
367
+ const { ready, seams, bad, ok, total, degraded } = seamSnapshot()
368
+ const next = ready ? 'ready' : 'degraded'
369
+ if (announced === next) return next
370
+ announced = next
371
+
372
+ const action = ready ? 'compaction-watch-ready' : 'compaction-watch-degraded'
373
+ const evidence = ready
374
+ ? `监察层就位:${WATCH_SEAMS.length} 条缝均已注册,压缩事件与撞窗告警都会进本日志` +
375
+ `(模块 ${ok}/${total} 报到,pid ${process.pid})`
376
+ : `监察层未就位:${bad.join(';')} —— 该缝未注册时,对应的压缩事件不会进本日志` +
377
+ `(模块 ${ok}/${total} 报到,pid ${process.pid})`
378
+
379
+ appendAudit({
380
+ ts: new Date().toISOString(),
381
+ // 进程级记录没有会话归属,用既有兜底口径 dsh-native,另以 scope 标明不是会话级留痕。
382
+ session: 'dsh-native',
383
+ action,
384
+ trigger: 'startup',
385
+ level: null,
386
+ basis: 'fg:compaction-watch',
387
+ scope: 'process',
388
+ pid: process.pid,
389
+ process_started_at: PROCESS_STARTED_AT,
390
+ watching: [...WATCH_SEAMS],
391
+ seams,
392
+ modules_ok: `${ok}/${total}`,
393
+ degraded_modules: degraded,
394
+ readonly: true,
395
+ evidence: clampText(evidence),
396
+ pardon: false,
397
+ })
398
+
399
+ if (!ready) {
400
+ emit(`压缩监察层未就位:${bad.join(';')} —— 这些缝未注册时,压缩事件不会进 AUDIT.log`)
401
+ }
402
+ return next
403
+ } catch (error) {
404
+ emit('压缩监察启动自证失败(不影响会话):', (error && error.message) || error)
405
+ return null
406
+ }
407
+ }
@@ -0,0 +1,10 @@
1
+ # focus-guard 原生 Cordis 插件挂载(3.0.5 阶段一 · 最小可行原型)
2
+ #
3
+ # 安装:dsh plugin --profile <name> add <本仓库 packages/core 目录>
4
+ # (bundle 补丁由 package.json 的 dsh.bundle.patch 指向本文件,随插件并入 profile 补丁层)
5
+ #
6
+ # ⚠ 与桥 @deepseek-ai/dsh-hooks-claude-code 互斥挂载(同一缝双监听=双跑,
7
+ # 见仓库根 cordis.patch.yml 同源教训与《审 3 号报告》风险 4)。
8
+ - insert:
9
+ - id: focus-guard-native
10
+ name: 'focus-guard'
@@ -0,0 +1,128 @@
1
+ /**
2
+ * focus-guard 原生插件 · 心跳(2026-10-18)
3
+ *
4
+ * 目的只有一个:**宿主外**能判断 FG 还在不在跑。插件挂掉/被炸/卡死时,
5
+ * 文件不再更新,读文件的人立刻看得出来。
6
+ *
7
+ * 设计(与最初序列图一致,但去掉常驻 watchdog 进程):
8
+ * · 插件内定时器每 10s 原子覆写 `<仓库根>/.focus-guard/status.json`;
9
+ * · 确认是按需的:`node src/dsh/status.mjs` 读文件算 lag,超 30s 判 STALE。
10
+ *
11
+ * 为什么不在插件里再跑一个轮询器:同进程的轮询器无法发现"本进程已死",
12
+ * 只能发现"没在写",等价于读文件本身。真 watchdog 必须是外部进程。
13
+ *
14
+ * 纪律:写入失败只 warn 一次,绝不抛出——心跳不能成为宿主的新故障点。
15
+ * 路径:与 AUDIT.log 同目录(`audit.mjs` 同一套 `../../../../` 上溯规则),
16
+ * `FG_STATUS_FILE` 可重定向(测试必须用它,否则写进真实工作区)。
17
+ */
18
+
19
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { dirname } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ /** 心跳周期;`FG_HEARTBEAT_INTERVAL_MS` 可覆盖(测试用)。 */
24
+ export const HEARTBEAT_INTERVAL_MS = Number(process.env.FG_HEARTBEAT_INTERVAL_MS) || 10_000
25
+
26
+ /** 超过该时长未更新即判 STALE;`FG_STALE_AFTER_MS` 可覆盖(测试用)。 */
27
+ export const STALE_AFTER_MS = Number(process.env.FG_STALE_AFTER_MS) || 30_000
28
+
29
+ /** 插件文件位于 <仓库根>/packages/core/src/dsh/ → 上溯 4 级到仓库根。 */
30
+ export const STATUS_FILE = fileURLToPath(new URL('../../../../.focus-guard/status.json', import.meta.url))
31
+
32
+ /** 心跳文件路径;与 `auditFilePath()` 同思路。 */
33
+ export function statusFilePath() {
34
+ return process.env.FG_STATUS_FILE || STATUS_FILE
35
+ }
36
+
37
+ // ── 启动自检:各执法模块报到表(2026-10-18,人类批示「哪个模块没报告就喊」)──
38
+ // 语义只有一条:`apply()` 里每个 `register()` 成功即报到,抛错即失败。
39
+ // 未报到的模块=该执法缝**可能没生效**,不能在降级状态下假装它在执法。
40
+
41
+ /** @type {Map<string, { ok: boolean, error?: string, at: string }>} */
42
+ const MODULES = new Map()
43
+
44
+ /** 登记一个模块的启动结果。 */
45
+ export function markModule(id, ok, error) {
46
+ MODULES.set(id, {
47
+ ok: ok === true,
48
+ ...(ok === true ? {} : { error: error === undefined ? '未报到' : String(error) }),
49
+ at: new Date().toISOString(),
50
+ })
51
+ }
52
+
53
+ /** 当前报到快照:{ 'id': 'OK' | 'FAIL: <msg>' }。 */
54
+ export function moduleSnapshot() {
55
+ const out = {}
56
+ for (const [id, row] of MODULES) out[id] = row.ok ? 'OK' : `FAIL: ${row.error ?? '未报到'}`
57
+ return out
58
+ }
59
+
60
+ /** 未报到的模块 id 列表。 */
61
+ export function degradedModules() {
62
+ return [...MODULES.entries()].filter(([, row]) => !row.ok).map(([id]) => id)
63
+ }
64
+
65
+ /**
66
+ * 模型侧告警文本;全部报到时返回 null(正常路径零注入)。
67
+ * 只在真的缺模块时占用系统提示 token。
68
+ */
69
+ export function livenessNote() {
70
+ const bad = degradedModules()
71
+ if (bad.length === 0) return null
72
+ const detail = bad.map((id) => `${id}(${MODULES.get(id)?.error ?? '未报到'})`).join(';')
73
+ return (
74
+ `[FG-DEGRADED] FocusGuard 启动自检:${MODULES.size - bad.length}/${MODULES.size} 个模块报到,` +
75
+ `缺失 ${bad.length} 个 —— ${detail}。` +
76
+ '这些缝的执法能力当前不可用,不要假设它们仍在拦;收尾时提示人类重启 DSH。'
77
+ )
78
+ }
79
+
80
+ /**
81
+ * 启动心跳:立即写一次,然后按周期覆写。返回停止函数。
82
+ * @param {{ warn?: (...parts: unknown[]) => void }} [options]
83
+ * @returns {() => void} 停止心跳
84
+ */
85
+ export function startHeartbeat({ warn } = {}) {
86
+ const file = statusFilePath()
87
+ const startedAt = new Date().toISOString()
88
+ let warned = false
89
+
90
+ const beat = () => {
91
+ const body = {
92
+ status: 'ALIVE',
93
+ started_at: startedAt,
94
+ last_heartbeat: new Date().toISOString(),
95
+ pid: process.pid,
96
+ interval_ms: HEARTBEAT_INTERVAL_MS,
97
+ stale_after_ms: STALE_AFTER_MS,
98
+ // 启动自检结果随心跳一起对外:读文件的进程能同时看到「还在跳」和「哪些模块没报到」。
99
+ modules_ok: `${MODULES.size - degradedModules().length}/${MODULES.size}`,
100
+ degraded: degradedModules(),
101
+ modules: moduleSnapshot(),
102
+ }
103
+ try {
104
+ mkdirSync(dirname(file), { recursive: true })
105
+ // 先写临时文件再 rename:外部读到的永远是完整 JSON,不会是写了一半的截断内容。
106
+ const tmp = `${file}.tmp`
107
+ writeFileSync(tmp, `${JSON.stringify(body)}\n`)
108
+ renameSync(tmp, file)
109
+ warned = false
110
+ } catch (error) {
111
+ if (!warned) {
112
+ warned = true
113
+ warn?.('心跳写入失败(不阻断宿主):', (error && error.message) || error)
114
+ }
115
+ }
116
+ }
117
+
118
+ // 首次写放到下一 tick:`startHeartbeat` 是自己也是最后一个报到模块,
119
+ // 同步写会让首帧快照少它一个(10/10 而不是 11/11),外面看到的就是错的完成度。
120
+ const kick = setTimeout(beat, 0)
121
+ const timer = setInterval(beat, HEARTBEAT_INTERVAL_MS)
122
+ // 心跳不该拖住宿主退出:unref 后它不参与事件循环引用计数。
123
+ if (typeof timer.unref === 'function') timer.unref()
124
+ return () => {
125
+ clearTimeout(kick)
126
+ clearInterval(timer)
127
+ }
128
+ }