add-coder 0.3.34 → 0.3.35

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 (147) hide show
  1. package/dist/index.js +21 -3
  2. package/package.json +3 -3
  3. package/templates/.add-coder-src-hash.json +144 -90
  4. package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -88
  5. package/templates/adapters/claude/hooks/notification.mjs +0 -4
  6. package/templates/adapters/claude/hooks/permission-denied.mjs +0 -4
  7. package/templates/adapters/claude/hooks/permission-gate.mjs +0 -4
  8. package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -5
  9. package/templates/adapters/claude/hooks/pre-compact.mjs +0 -4
  10. package/templates/adapters/claude/hooks/pre-tool-use.mjs +0 -4
  11. package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -4
  12. package/templates/adapters/claude/hooks/session-end.mjs +0 -4
  13. package/templates/adapters/claude/hooks/session-start.mjs +65 -5
  14. package/templates/adapters/claude/hooks/stop-check.mjs +6 -87
  15. package/templates/adapters/claude/hooks/stop-failure.mjs +0 -4
  16. package/templates/adapters/claude/hooks/subagent-guard.mjs +0 -4
  17. package/templates/adapters/claude/hooks/subagent-stop.mjs +0 -4
  18. package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -88
  19. package/templates/adapters/codex/hooks/notification.mjs +0 -4
  20. package/templates/adapters/codex/hooks/permission-gate.mjs +0 -4
  21. package/templates/adapters/codex/hooks/post-tool-failure.mjs +0 -4
  22. package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -5
  23. package/templates/adapters/codex/hooks/pre-compact.mjs +0 -4
  24. package/templates/adapters/codex/hooks/pre-tool-use.mjs +0 -4
  25. package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -4
  26. package/templates/adapters/codex/hooks/session-end.mjs +0 -4
  27. package/templates/adapters/codex/hooks/session-start.mjs +65 -5
  28. package/templates/adapters/codex/hooks/stop-check.mjs +6 -87
  29. package/templates/adapters/codex/hooks/subagent-guard.mjs +0 -4
  30. package/templates/adapters/codex/hooks/subagent-stop.mjs +0 -4
  31. package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -88
  32. package/templates/adapters/qoder/hooks/notification.mjs +0 -4
  33. package/templates/adapters/qoder/hooks/permission-gate.mjs +0 -4
  34. package/templates/adapters/qoder/hooks/post-tool-failure.mjs +0 -4
  35. package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -5
  36. package/templates/adapters/qoder/hooks/pre-compact.mjs +0 -4
  37. package/templates/adapters/qoder/hooks/pre-tool-use.mjs +0 -4
  38. package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -4
  39. package/templates/adapters/qoder/hooks/review-checklist.mjs +0 -4
  40. package/templates/adapters/qoder/hooks/session-end.mjs +0 -4
  41. package/templates/adapters/qoder/hooks/session-start.mjs +67 -5
  42. package/templates/adapters/qoder/hooks/stop-check.mjs +6 -87
  43. package/templates/adapters/qoder/hooks/subagent-guard.mjs +0 -4
  44. package/templates/adapters/qoder/hooks/subagent-stop.mjs +0 -4
  45. package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -88
  46. package/templates/adapters/trae/hooks/notification.mjs +0 -4
  47. package/templates/adapters/trae/hooks/permission-gate.mjs +0 -4
  48. package/templates/adapters/trae/hooks/post-tool-failure.mjs +0 -4
  49. package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -5
  50. package/templates/adapters/trae/hooks/pre-compact.mjs +0 -4
  51. package/templates/adapters/trae/hooks/pre-tool-use.mjs +0 -4
  52. package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -4
  53. package/templates/adapters/trae/hooks/session-end.mjs +0 -4
  54. package/templates/adapters/trae/hooks/session-start.mjs +65 -5
  55. package/templates/adapters/trae/hooks/stop-check.mjs +6 -87
  56. package/templates/adapters/trae/hooks/subagent-guard.mjs +0 -4
  57. package/templates/adapters/trae/hooks/subagent-stop.mjs +0 -4
  58. package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -88
  59. package/templates/adapters/vscode/hooks/notification.mjs +0 -4
  60. package/templates/adapters/vscode/hooks/permission-gate.mjs +0 -4
  61. package/templates/adapters/vscode/hooks/post-tool-failure.mjs +0 -4
  62. package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -5
  63. package/templates/adapters/vscode/hooks/pre-compact.mjs +0 -4
  64. package/templates/adapters/vscode/hooks/pre-tool-use.mjs +0 -4
  65. package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -4
  66. package/templates/adapters/vscode/hooks/session-end.mjs +0 -4
  67. package/templates/adapters/vscode/hooks/session-start.mjs +65 -5
  68. package/templates/adapters/vscode/hooks/stop-check.mjs +6 -87
  69. package/templates/adapters/vscode/hooks/subagent-guard.mjs +0 -4
  70. package/templates/adapters/vscode/hooks/subagent-stop.mjs +0 -4
  71. package/templates/core/governance/doc-format-guard.ts +29 -112
  72. package/templates/core/governance/post-tool-router.ts +33 -1
  73. package/templates/core/governance/prompt-router.ts +47 -0
  74. package/templates/core/governance/rules.ts +0 -4
  75. package/templates/core/governance/session-start-guard.ts +48 -1
  76. package/templates/core/governance/stop-router.ts +7 -101
  77. package/templates/core/prisma/add.prisma +203 -0
  78. package/templates/core/scripts/db-ensure.sh +61 -2
  79. package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
  80. package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
  81. package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
  82. package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
  83. package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
  84. package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
  85. package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
  86. package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
  87. package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
  88. package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
  89. package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
  90. package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
  91. package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
  92. package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
  93. package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
  94. package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
  95. package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
  96. package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
  97. package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
  98. package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
  99. package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
  100. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
  101. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
  102. package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
  103. package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
  104. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
  105. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
  106. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
  107. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
  108. package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
  109. package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
  110. package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
  111. package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
  112. package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
  113. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
  114. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
  115. package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
  116. package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
  117. package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
  118. package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +41 -3
  119. package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
  120. package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +2 -2
  121. package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
  122. package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
  123. package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
  124. package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
  125. package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
  126. package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
  127. package/templates/core/scripts/mcp-server.ts +36 -0
  128. package/templates/core/templates/checklist-template.md +13 -0
  129. package/templates/core/templates/review-implementation-template.md +24 -0
  130. package/templates/core/templates/review-template.md +16 -0
  131. package/templates/core/validation/index.ts +136 -0
  132. package/templates/core/validation/policy.ts +91 -0
  133. package/templates/core/validation/registry.ts +61 -0
  134. package/templates/core/validation/schema-validator.ts +277 -0
  135. package/templates/core/validation/validators/add-route.ts +32 -0
  136. package/templates/core/validation/validators/checklist.ts +48 -0
  137. package/templates/core/validation/validators/handoff.ts +46 -0
  138. package/templates/core/validation/validators/hitl.ts +22 -0
  139. package/templates/core/validation/validators/index.ts +52 -0
  140. package/templates/core/validation/validators/plan.ts +20 -0
  141. package/templates/core/validation/validators/report.ts +16 -0
  142. package/templates/core/validation/validators/review.ts +30 -0
  143. package/templates/core/validation/validators/spec.ts +25 -0
  144. package/templates/core/validation/validators/tasks.ts +40 -0
  145. package/templates/core/validation/validators/types.ts +32 -0
  146. package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
  147. package/templates/core/scripts/mcp-server/shared/dps-spec-ref.ts +0 -17
@@ -0,0 +1,153 @@
1
+ /*
2
+ * Evidence 自动采集(Spec §10/§11,Plan §9.2 PostToolUse 行)
3
+ *
4
+ * 两段式:
5
+ * ① Hook 侧(同步 ≤200ms,无 DB):白名单判定 + 事件入队(evidence-queue.jsonl append-only)
6
+ * —— classifyEvidenceSource / buildEvidenceEvent 为纯函数,供 post-tool-router 直接引用
7
+ * ② Job 侧(异步,可重试):drainEvidenceQueue 消费队列 → upsert Evidence(幂等键
8
+ * repositoryRef+sourceType+sourceRef+contentHash,与 add.prisma @@unique 一一对应)
9
+ * 消费进度落 evidence-queue.offset(字节偏移),重放幂等:同偏移不重复处理,
10
+ * 同事件重复消费由 upsert 幂等吸收。
11
+ */
12
+ import { createHash } from "node:crypto"
13
+ import { existsSync, mkdirSync, openSync, readFileSync, readSync, closeSync, statSync, writeFileSync, renameSync } from "node:fs"
14
+ import { dirname, join } from "node:path"
15
+ import type { AddMemoryEvidenceRow, TableDelegate } from "../../db-types.js"
16
+ import { EVIDENCE_OFFSET_FILE, EVIDENCE_QUEUE_FILE, MEMORY_DIR_NAME } from "../switches.js"
17
+
18
+ // ── ① Hook 侧纯函数(无 IO 之外的依赖,无 DB) ──
19
+
20
+ export type EvidenceSourceType = "PLAN" | "SPEC" | "HANDOFF" | "DEV_OPERATION"
21
+
22
+ export interface EvidenceEvent {
23
+ dedupKey: string
24
+ sourceType: EvidenceSourceType
25
+ sourceRef: string
26
+ excerpt: string
27
+ occurredAt: string
28
+ }
29
+
30
+ /** 白名单分类:命中返回 sourceType,未命中返回 null(spec §10:按白名单采集) */
31
+ export function classifyEvidenceSource(filePath: string): EvidenceSourceType | null {
32
+ const p = filePath.replace(/\\/g, "/")
33
+ if (/(^|\/)plans\/[^/]*-plan-v\d+\.md$/.test(p)) return "PLAN"
34
+ if (/(^|\/)plans\/[^/]*-add-route-v\d+\.md$/.test(p)) return "PLAN"
35
+ if (/(^|\/)specs\/.+\.md$/.test(p)) return "SPEC"
36
+ if (/handoff[^/]*\.md$/i.test(p)) return "HANDOFF"
37
+ if (/(^|\/)reviews\/.+\.md$/.test(p)) return "DEV_OPERATION"
38
+ return null
39
+ }
40
+
41
+ /** 构造采证事件(幂等 key = sha256(sourceType|sourceRef|excerpt)):同文件同内容重复写入被吸收 */
42
+ export function buildEvidenceEvent(filePath: string, excerpt: string, occurredAt = new Date()): EvidenceEvent {
43
+ const sourceType = classifyEvidenceSource(filePath)
44
+ if (!sourceType) throw new Error(`非白名单路径: ${filePath}`)
45
+ const sourceRef = filePath
46
+ const dedupKey = createHash("sha256")
47
+ .update(`${sourceType}|${sourceRef}|${excerpt}`, "utf8")
48
+ .digest("hex")
49
+ .slice(0, 32)
50
+ return { dedupKey, sourceType, sourceRef, excerpt: excerpt.slice(0, 500), occurredAt: occurredAt.toISOString() }
51
+ }
52
+
53
+ // ── ② Job 侧消费(异步、可重试) ──
54
+
55
+ export interface DrainDeps {
56
+ projectDir: string
57
+ magicDir: string
58
+ repositoryRef: string
59
+ evidenceDb: Pick<TableDelegate<AddMemoryEvidenceRow>, "upsert">
60
+ }
61
+
62
+ export interface DrainResult {
63
+ processed: number
64
+ skipped: number
65
+ errors: string[]
66
+ newOffset: number
67
+ }
68
+
69
+ /** 读 offset 标记(缺失/损坏 → 0,从头重放;upsert 幂等保证安全) */
70
+ export function readOffset(projectDir: string, magicDir: string): number {
71
+ try {
72
+ const f = join(projectDir, magicDir, MEMORY_DIR_NAME, EVIDENCE_OFFSET_FILE)
73
+ if (!existsSync(f)) return 0
74
+ const n = Number(readFileSync(f, "utf-8").trim())
75
+ return Number.isFinite(n) && n >= 0 ? n : 0
76
+ } catch {
77
+ return 0
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 消费 evidence 队列:从上次偏移继续,逐行 upsert Evidence。
83
+ * fail-open 粒度到行:坏行跳过并计入 errors,不阻塞后续。
84
+ */
85
+ export async function drainEvidenceQueue(deps: DrainDeps): Promise<DrainResult> {
86
+ const queueFile = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, EVIDENCE_QUEUE_FILE)
87
+ const result: DrainResult = { processed: 0, skipped: 0, errors: [], newOffset: readOffset(deps.projectDir, deps.magicDir) }
88
+ if (!existsSync(queueFile)) return result
89
+
90
+ const size = (() => { try { return statSync(queueFile).size } catch { return 0 } })()
91
+ if (size <= result.newOffset) return result // 无增量
92
+
93
+ // 按偏移读取增量字节(避免大文件全量加载)
94
+ const fd = openSync(queueFile, "r")
95
+ let chunk: string
96
+ try {
97
+ const buf = Buffer.alloc(size - result.newOffset)
98
+ readSync(fd, buf, 0, buf.length, result.newOffset)
99
+ chunk = buf.toString("utf-8")
100
+ } finally {
101
+ closeSync(fd)
102
+ }
103
+
104
+ let cursor = result.newOffset
105
+ for (const line of chunk.split("\n")) {
106
+ const lineBytes = Buffer.byteLength(line, "utf-8") + 1 // + '\n'
107
+ cursor += lineBytes
108
+ const trimmed = line.trim()
109
+ if (!trimmed) continue
110
+ let ev: EvidenceEvent
111
+ try {
112
+ ev = JSON.parse(trimmed) as EvidenceEvent
113
+ } catch {
114
+ result.skipped++
115
+ result.errors.push(`坏行@${cursor - lineBytes}: JSON 解析失败`)
116
+ continue
117
+ }
118
+ try {
119
+ await deps.evidenceDb.upsert({
120
+ where: {
121
+ repositoryRef_sourceType_sourceRef_contentHash: {
122
+ repositoryRef: deps.repositoryRef,
123
+ sourceType: ev.sourceType,
124
+ sourceRef: ev.sourceRef,
125
+ contentHash: ev.dedupKey,
126
+ },
127
+ },
128
+ create: {
129
+ repositoryRef: deps.repositoryRef,
130
+ sourceType: ev.sourceType,
131
+ sourceRef: ev.sourceRef,
132
+ excerpt: ev.excerpt,
133
+ contentHash: ev.dedupKey,
134
+ occurredAt: new Date(ev.occurredAt),
135
+ } as Partial<AddMemoryEvidenceRow>,
136
+ update: {},
137
+ })
138
+ result.processed++
139
+ } catch (e) {
140
+ result.skipped++
141
+ result.errors.push(`落库失败 ${ev.sourceRef}: ${e instanceof Error ? e.message : String(e)}`)
142
+ }
143
+ }
144
+
145
+ // 原子推进 offset(先写后改:offset 落后只会重放,upsert 幂等吸收)
146
+ result.newOffset = cursor
147
+ const offsetFile = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, EVIDENCE_OFFSET_FILE)
148
+ mkdirSync(dirname(offsetFile), { recursive: true })
149
+ const tmp = `${offsetFile}.tmp-${process.pid}`
150
+ writeFileSync(tmp, String(cursor), "utf-8")
151
+ renameSync(tmp, offsetFile)
152
+ return result
153
+ }
@@ -0,0 +1,114 @@
1
+ /*
2
+ * L1/L2 召回快照生成(Spec §10:session-start 仅注入 repository 级 L1 小上下文)
3
+ *
4
+ * 设计约束(Plan §9.3):同步 Hook ≤200ms 且无 DB 依赖 → Hook 只读本模块预计算的快照文件;
5
+ * 快照由本 job 异步刷新(consolidation / 手动 CLI / Gate 后触发)。
6
+ * 原子写:tmp + rename,Hook 读侧永不遇到半文件。
7
+ */
8
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs"
9
+ import { dirname, join } from "node:path"
10
+ import { recallPipeline, type MemoryRowLike } from "../retrieval/pipeline.js"
11
+ import type { RecallAuditStore } from "../retrieval/recall-writer.js"
12
+ import type { LexicalSearchAdapter, RecalledMemory } from "../retrieval/types.js"
13
+ import { L1_SNAPSHOT_FILE, L2_SNAPSHOT_FILE, MEMORY_DIR_NAME, memoryMaxTokens } from "../switches.js"
14
+
15
+ export interface SnapshotDeps {
16
+ repositoryRef: string
17
+ projectDir: string
18
+ magicDir: string
19
+ lexical: LexicalSearchAdapter[]
20
+ fetchByIds(ids: string[]): Promise<MemoryRowLike[]>
21
+ fetchEvidenceSourceRefs(memoryIds: string[]): Promise<Map<string, string[]>>
22
+ audit: RecallAuditStore
23
+ now?: Date
24
+ }
25
+
26
+ export interface SnapshotResult {
27
+ path: string
28
+ itemCount: number
29
+ injectedTokens: number
30
+ recallId: string | null
31
+ }
32
+
33
+ /** 渲染带来源边界标签的注入文本(Plan §9.3:Recall 注入采用明确的数据边界与来源标签) */
34
+ export function renderSnapshotMarkdown(level: "L1" | "L2", items: RecalledMemory[], generatedAt: Date): string {
35
+ const lines = [
36
+ `[Memory ${level} · 来源: AddMemory 治理库 · 生成于 ${generatedAt.toISOString()}]`,
37
+ `<agent-memory source="add-memory" trust="governed">`,
38
+ ]
39
+ for (const it of items) {
40
+ lines.push(
41
+ `- [${it.kind}] ${it.topic}(置信 ${it.confidence.toFixed(2)})`,
42
+ ` ${it.content.length > 120 ? it.content.slice(0, 120) + "…" : it.content}`,
43
+ )
44
+ if (it.sourceRefs.length > 0) lines.push(` 来源: ${it.sourceRefs.join(", ")}`)
45
+ }
46
+ lines.push(`</agent-memory>`)
47
+ return lines.join("\n") + "\n"
48
+ }
49
+
50
+ /** 刷新 L1 快照:repository 级核心约束/决策/陷阱/约定 */
51
+ export async function refreshL1Snapshot(deps: SnapshotDeps): Promise<SnapshotResult> {
52
+ const maxTokens = memoryMaxTokens()
53
+ const result = await recallPipeline(
54
+ {
55
+ query: "核心约束 关键决策 常见陷阱 项目约定",
56
+ stage: "session-start",
57
+ repositoryRef: deps.repositoryRef,
58
+ scopeCtx: { repository: deps.repositoryRef },
59
+ maxTokens,
60
+ kinds: ["CONSTRAINT", "DECISION", "PITFALL", "CONVENTION"],
61
+ limit: 20,
62
+ consumerRef: "memory-jobs:refresh-l1",
63
+ },
64
+ {
65
+ lexical: deps.lexical,
66
+ fetchByIds: deps.fetchByIds,
67
+ fetchEvidenceSourceRefs: deps.fetchEvidenceSourceRefs,
68
+ audit: deps.audit,
69
+ degradedMode: "fts-only(snapshot-job)",
70
+ now: deps.now,
71
+ },
72
+ )
73
+ const path = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, L1_SNAPSHOT_FILE)
74
+ atomicWrite(path, renderSnapshotMarkdown("L1", result.items, deps.now ?? new Date()))
75
+ return { path, itemCount: result.items.length, injectedTokens: result.injectedTokens, recallId: result.recallId }
76
+ }
77
+
78
+ /** 刷新 L2 快照:按给定 query/stage/scope(供 prompt-router 提示与人工查阅) */
79
+ export async function refreshL2Snapshot(
80
+ deps: SnapshotDeps,
81
+ query: string,
82
+ stage: string,
83
+ maxTokens = 1200,
84
+ ): Promise<SnapshotResult> {
85
+ const result = await recallPipeline(
86
+ {
87
+ query,
88
+ stage,
89
+ repositoryRef: deps.repositoryRef,
90
+ scopeCtx: { repository: deps.repositoryRef },
91
+ maxTokens,
92
+ limit: 20,
93
+ consumerRef: "memory-jobs:refresh-l2",
94
+ },
95
+ {
96
+ lexical: deps.lexical,
97
+ fetchByIds: deps.fetchByIds,
98
+ fetchEvidenceSourceRefs: deps.fetchEvidenceSourceRefs,
99
+ audit: deps.audit,
100
+ degradedMode: "fts-only(snapshot-job)",
101
+ now: deps.now,
102
+ },
103
+ )
104
+ const path = join(deps.projectDir, deps.magicDir, MEMORY_DIR_NAME, L2_SNAPSHOT_FILE)
105
+ atomicWrite(path, renderSnapshotMarkdown("L2", result.items, deps.now ?? new Date()))
106
+ return { path, itemCount: result.items.length, injectedTokens: result.injectedTokens, recallId: result.recallId }
107
+ }
108
+
109
+ function atomicWrite(path: string, content: string): void {
110
+ mkdirSync(dirname(path), { recursive: true })
111
+ const tmp = `${path}.tmp-${process.pid}`
112
+ writeFileSync(tmp, content, "utf-8")
113
+ renameSync(tmp, path)
114
+ }
@@ -0,0 +1,134 @@
1
+ /*
2
+ * 工具侧阶段确定性召回(Plan §3.1 方案 B1 / Spec §2 §DeterministicRecall)
3
+ *
4
+ * 与 stage-words.ts 的分工:
5
+ * - stage-words.ts:纯函数,Hook 侧识别阶段词(无 DB)
6
+ * - 本模块:有 DB 的工具侧落点,复用既有 recallPipeline(不另起一套召回实现)
7
+ *
8
+ * 三条硬约束:
9
+ * 1. 阶段白名单命中才召回——未命中直接返回,不访问 DB、不写审计;
10
+ * 2. ADD_MEMORY_RECALL_MODE=off 时整体停用(与 recall_memory 工具同语义);
11
+ * 3. fail-open:管线异常只降级为 skippedReason="pipeline-error",绝不抛出,
12
+ * 不阻塞调用它的门禁/交接流程。
13
+ */
14
+ import { recallPipeline, type RecallPipelineDeps, type RecallPipelineResult } from "../retrieval/pipeline.js"
15
+ import { buildContext, estimateTokens } from "../retrieval/context-builder.js"
16
+ import { recallMode, type RecallMode } from "../switches.js"
17
+ import type { ScopeContext } from "../domain/scope.js"
18
+ import { isRecallStage, RECALL_STAGES, type RecallStage } from "./stage-words.js"
19
+
20
+ /** 默认 token 预算(Spec §10:ADD_MEMORY_MAX_TOKENS 默认 600,此处与 Recall 通道对齐) */
21
+ export const DEFAULT_STAGE_RECALL_MAX_TOKENS = 600
22
+
23
+ export type StageRecallSkipReason = "stage-not-whitelisted" | "recall-off" | "pipeline-error"
24
+
25
+ export interface StageRecallInput {
26
+ /** 调用方声明的阶段;不在白名单内即不召回 */
27
+ stage: string
28
+ query: string
29
+ repositoryRef: string
30
+ scopeCtx?: Partial<ScopeContext>
31
+ planKeyword?: string
32
+ specRef?: string
33
+ maxTokens?: number
34
+ limit?: number
35
+ consumerRef?: string
36
+ diagnostic?: boolean
37
+ }
38
+
39
+ export interface StageRecallResult {
40
+ injected: boolean
41
+ stage: RecallStage | null
42
+ context?: string
43
+ auditId?: string | null
44
+ degradedMode?: string | null
45
+ itemCount: number
46
+ usedTokens: number
47
+ latencyMs: number
48
+ skippedReason?: StageRecallSkipReason
49
+ error?: string
50
+ }
51
+
52
+ /**
53
+ * 按阶段执行确定性召回。deps 由调用方(MCP 工具)注入,便于测试与复用。
54
+ */
55
+ export async function recallForStage(
56
+ input: StageRecallInput,
57
+ deps: RecallPipelineDeps,
58
+ options: { mode?: RecallMode } = {},
59
+ ): Promise<StageRecallResult> {
60
+ const startedAt = Date.now()
61
+ const empty = (): Omit<StageRecallResult, "skippedReason" | "stage"> => ({
62
+ injected: false,
63
+ itemCount: 0,
64
+ usedTokens: 0,
65
+ latencyMs: Math.max(0, Date.now() - startedAt),
66
+ })
67
+
68
+ if (!isRecallStage(input.stage)) {
69
+ return { ...empty(), stage: null, skippedReason: "stage-not-whitelisted" }
70
+ }
71
+ const mode = options.mode ?? recallMode()
72
+ if (mode === "off") {
73
+ return { ...empty(), stage: input.stage, skippedReason: "recall-off" }
74
+ }
75
+
76
+ const stage = input.stage
77
+ const maxTokens = input.maxTokens ?? DEFAULT_STAGE_RECALL_MAX_TOKENS
78
+ const scopeCtx: ScopeContext = {
79
+ repository: input.repositoryRef,
80
+ planKeyword: input.planKeyword,
81
+ specRef: input.specRef,
82
+ ...input.scopeCtx,
83
+ }
84
+
85
+ try {
86
+ const result: RecallPipelineResult = await recallPipeline(
87
+ {
88
+ query: input.query,
89
+ stage,
90
+ repositoryRef: input.repositoryRef,
91
+ scopeCtx,
92
+ maxTokens,
93
+ limit: input.limit ?? 20,
94
+ consumerRef: input.consumerRef ?? `mcp:stage-recall:${stage}`,
95
+ diagnostic: input.diagnostic ?? false,
96
+ },
97
+ deps,
98
+ )
99
+
100
+ const budget = buildContext(
101
+ result.items.map((item) => ({
102
+ memoryId: item.memoryId,
103
+ kind: item.kind,
104
+ finalScore: item.scoreBreakdown?.final ?? item.confidence,
105
+ tokens: estimateTokens(item.content),
106
+ content: item.content,
107
+ sourceRefs: item.sourceRefs,
108
+ })),
109
+ maxTokens,
110
+ )
111
+
112
+ return {
113
+ injected: budget.selected.length > 0,
114
+ stage,
115
+ context: budget.selected.map((i) => i.content).join("\n\n"),
116
+ auditId: result.recallId,
117
+ degradedMode: result.degradedMode,
118
+ itemCount: budget.selected.length,
119
+ usedTokens: budget.usedTokens,
120
+ latencyMs: result.latencyMs,
121
+ }
122
+ } catch (error) {
123
+ // fail-open:召回失败不阻塞门禁/交接;错误信息进审计与返回值
124
+ return {
125
+ ...empty(),
126
+ stage,
127
+ skippedReason: "pipeline-error",
128
+ error: error instanceof Error ? error.message : String(error),
129
+ }
130
+ }
131
+ }
132
+
133
+ /** 供工具/文档展示的白名单(单一事实源来自 stage-words) */
134
+ export { RECALL_STAGES }
@@ -0,0 +1,217 @@
1
+ /*
2
+ * Gate → MetricSnapshot 幂等采证(Plan §3.1 方案 A1 / Spec §1 §GateMetric)
3
+ *
4
+ * 设计要点:
5
+ * 1. 与评分解耦:评分逻辑保持只读,采证在评分完成后单独提交(Review P0 #1 / Plan §2.1);
6
+ * 2. 幂等键:sourceRef = `{gate}:{planKeyword}:{runId}`,配合表级唯一键
7
+ * (repositoryRef, metricType, sourceRef) —— 同 runId 重放不产生重复快照;
8
+ * 3. fail-open:任何异常都不向上抛,返回 outcome="bypassed" + degradedReason,
9
+ * 由调用方保证评分照常返回(验收项「采证失败旁路」);
10
+ * 4. 失败路径审计密度不低于成功路径(ADD-6):buildGateCaptureDetail 对三种 outcome
11
+ * 返回同构字段集。
12
+ */
13
+ import type { AddMetricSnapshotRow, TableDelegate } from "../../db-types.js"
14
+ import { AddMetricSnapshotRowSchema, validatedDelegate } from "../../db-types.js"
15
+ import { MemoryError } from "../domain/errors.js"
16
+ import { createHash } from "node:crypto"
17
+
18
+ export type GateKind = "check_dps" | "check_rahs"
19
+ export type GateWriteOutcome = "written" | "skipped_duplicate" | "bypassed"
20
+
21
+ /** 门禁指标类型字面量(Spec §10 契约登记表) */
22
+ export const GATE_METRIC_TYPE = {
23
+ check_dps: "DPS_TOTAL",
24
+ check_rahs: "RAHS_TOTAL",
25
+ latency: "GATE_LATENCY_MS",
26
+ } as const
27
+
28
+ /** 采证耗时增量上限(ms)——验收项「工具响应耗时增量 ≤50ms」 */
29
+ export const GATE_CAPTURE_TOLERANCE_MS = 50
30
+
31
+ export interface GateMetricWriteInput {
32
+ gate: GateKind
33
+ planKeyword: string
34
+ runId: string
35
+ metricType?: string
36
+ score: number
37
+ repository: string
38
+ dimensionScores?: Record<string, number>
39
+ baseline?: number | null
40
+ unit?: string | null
41
+ specRef?: string | null
42
+ commitSha?: string | null
43
+ }
44
+
45
+ export interface GateMetricWriteResult {
46
+ outcome: GateWriteOutcome
47
+ sourceRef: string
48
+ metricId?: string
49
+ degradedReason?: string
50
+ elapsedMs: number
51
+ }
52
+
53
+ export interface GateWriterDeps {
54
+ /** 采证目标表;测试可注入内存实现 */
55
+ metricDb: Pick<TableDelegate<AddMetricSnapshotRow>, "findUnique" | "upsert">
56
+ /** 时钟注入(测试用),默认 Date.now */
57
+ now?: () => number
58
+ }
59
+
60
+ /**
61
+ * 幂等键构造。空值归一为 "-",避免出现 `::` 与前缀碰撞。
62
+ */
63
+ export function buildGateSourceRef(gate: GateKind, planKeyword: string, runId: string): string {
64
+ const norm = (v: string | undefined | null): string => {
65
+ const t = (v ?? "").trim()
66
+ return t.length > 0 ? t : "-"
67
+ }
68
+ return `${gate}:${norm(planKeyword)}:${norm(runId)}`
69
+ }
70
+
71
+ /**
72
+ * 由被评分产物的内容派生 runId。
73
+ *
74
+ * 语义:同一份文档重复跑同一门禁 → 同一幂等键 → 判定为重复采证(skipped_duplicate);
75
+ * 文档内容变化 → 新幂等键 → 产生新的证据快照。这样「重放」不会因为时间戳不同而假性去重,
76
+ * 也不需要调用方额外传参(门禁工具入参契约保持不变)。
77
+ */
78
+ export function deriveGateRunId(gate: GateKind, planKeyword: string, seed: string): string {
79
+ const digest = createHash("sha256")
80
+ .update(`${gate}|${planKeyword}|${seed}`)
81
+ .digest("hex")
82
+ return `auto-${digest.slice(0, 12)}`
83
+ }
84
+
85
+ /** 唯一键参数(与 prisma @@unique([repositoryRef, metricType, sourceRef]) 对应) */
86
+ function uniqueWhere(repositoryRef: string, metricType: string, sourceRef: string) {
87
+ return { repositoryRef_metricType_sourceRef: { repositoryRef, metricType, sourceRef } }
88
+ }
89
+
90
+ function isUniqueViolation(error: unknown): boolean {
91
+ const code = (error as { code?: string } | null)?.code
92
+ return code === "P2002"
93
+ }
94
+
95
+ /**
96
+ * 写入一次门禁指标快照。**不抛异常**:失败返回 bypassed,由调用方继续返回评分。
97
+ */
98
+ export async function writeGateMetric(
99
+ input: GateMetricWriteInput,
100
+ deps: GateWriterDeps,
101
+ ): Promise<GateMetricWriteResult> {
102
+ const now = deps.now ?? Date.now
103
+ const startedAt = now()
104
+ const metricType = input.metricType ?? GATE_METRIC_TYPE[input.gate]
105
+ const sourceRef = buildGateSourceRef(input.gate, input.planKeyword, input.runId)
106
+ const elapsed = () => Math.max(0, now() - startedAt)
107
+
108
+ try {
109
+ if (!input.repository || input.repository.trim().length === 0) {
110
+ throw new MemoryError("ERR_REPOSITORY_MISMATCH", "repositoryRef 为空,拒绝采证")
111
+ }
112
+ if (!Number.isFinite(input.score)) {
113
+ throw new MemoryError("ERR_INVARIANT", "score 非有限数值,拒绝采证")
114
+ }
115
+
116
+ const existing = await deps.metricDb.findUnique({
117
+ where: uniqueWhere(input.repository, metricType, sourceRef),
118
+ })
119
+ if (existing) {
120
+ // 同 runId 重放:既有快照是证据,不覆写
121
+ return { outcome: "skipped_duplicate", metricId: existing.id, sourceRef, elapsedMs: elapsed() }
122
+ }
123
+
124
+ const created = await deps.metricDb.upsert({
125
+ where: uniqueWhere(input.repository, metricType, sourceRef),
126
+ create: {
127
+ repositoryRef: input.repository,
128
+ metricType,
129
+ value: input.score,
130
+ baseline: input.baseline ?? null,
131
+ delta: input.baseline == null ? null : input.score - input.baseline,
132
+ unit: input.unit ?? null,
133
+ planKeyword: input.planKeyword || null,
134
+ specRef: input.specRef ?? null,
135
+ commitSha: input.commitSha ?? null,
136
+ sourceRef,
137
+ metadata: {
138
+ gate: input.gate,
139
+ runId: input.runId,
140
+ dimensionScores: input.dimensionScores ?? null,
141
+ capture: "gate-writer",
142
+ },
143
+ measuredAt: new Date(now()),
144
+ },
145
+ // 幂等:并发下由唯一键裁定,update 不改写既有证据
146
+ update: {},
147
+ })
148
+ return { outcome: "written", metricId: created.id, sourceRef, elapsedMs: elapsed() }
149
+ } catch (error) {
150
+ if (isUniqueViolation(error)) {
151
+ return { outcome: "skipped_duplicate", sourceRef, elapsedMs: elapsed() }
152
+ }
153
+ // fail-open:采证失败绝不阻塞门禁返回评分
154
+ return {
155
+ outcome: "bypassed",
156
+ sourceRef,
157
+ degradedReason: error instanceof Error ? error.message : String(error),
158
+ elapsedMs: elapsed(),
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 审计明细(ADD-6:成功/去重/旁路三态返回同构字段集,失败路径不得更稀疏)。
165
+ */
166
+ export function buildGateCaptureDetail(
167
+ input: GateMetricWriteInput,
168
+ result: GateMetricWriteResult,
169
+ ): Record<string, unknown> {
170
+ return {
171
+ gate: input.gate,
172
+ planKeyword: input.planKeyword,
173
+ runId: input.runId,
174
+ metricType: input.metricType ?? GATE_METRIC_TYPE[input.gate],
175
+ sourceRef: result.sourceRef,
176
+ outcome: result.outcome,
177
+ metricId: result.metricId ?? null,
178
+ score: input.score,
179
+ dimensionCount: input.dimensionScores ? Object.keys(input.dimensionScores).length : 0,
180
+ baseline: input.baseline ?? null,
181
+ degradedReason: result.degradedReason ?? null,
182
+ elapsedMs: result.elapsedMs,
183
+ overTolerance: result.elapsedMs > GATE_CAPTURE_TOLERANCE_MS,
184
+ }
185
+ }
186
+
187
+ /** 单行摘要:供 MCP 工具在响应末尾追加(三态均返回非空文本) */
188
+ export function formatGateCaptureSummary(detail: Record<string, unknown>): string {
189
+ const bits = [
190
+ `结果: ${String(detail.outcome)}`,
191
+ `幂等键: ${String(detail.sourceRef)}`,
192
+ `耗时: ${String(detail.elapsedMs)}ms`,
193
+ ]
194
+ if (typeof detail.metricId === "string" && detail.metricId.length > 0) {
195
+ bits.push(`metricId: ${detail.metricId}`)
196
+ }
197
+ if (typeof detail.degradedReason === "string" && detail.degradedReason.length > 0) {
198
+ bits.push(`旁路原因: ${detail.degradedReason}(评分不受影响)`)
199
+ }
200
+ if (detail.overTolerance === true) {
201
+ bits.push(`⚠️ 超出 ${GATE_CAPTURE_TOLERANCE_MS}ms 容差`)
202
+ }
203
+ return bits.join(" | ")
204
+ }
205
+
206
+ /**
207
+ * 便捷装配:把 MCP 工具侧的 prisma delegate 包装为通过行校验的采证依赖。
208
+ */
209
+ export function createGateWriterDeps(rawMetricDelegate: unknown): GateWriterDeps {
210
+ return {
211
+ metricDb: validatedDelegate<AddMetricSnapshotRow>(
212
+ rawMetricDelegate,
213
+ AddMetricSnapshotRowSchema,
214
+ "AddMetricSnapshot",
215
+ ),
216
+ }
217
+ }
@@ -0,0 +1,69 @@
1
+ /*
2
+ * 阶段词识别(Plan §9.1 / Spec §2 §DeterministicRecall)
3
+ *
4
+ * 本模块是**纯函数**:零运行时依赖、零 IO、零 DB。
5
+ * 存在意义:governance Hook(同步 spawn、≤200ms 预算、无 DB 访问权限)需要识别
6
+ * 「现在处于哪个 ADD 阶段」,以便产出确定性召回提示;而真正的召回落点在有 DB 的
7
+ * MCP 工具侧(gate-recall.ts)。两者共用同一份阶段白名单,避免 Hook 与工具侧漂移。
8
+ */
9
+
10
+ export const RECALL_STAGES = ["plan-start", "spec-start", "dps", "rahs", "handoff"] as const
11
+ export type RecallStage = (typeof RECALL_STAGES)[number]
12
+
13
+ export function isRecallStage(value: string): value is RecallStage {
14
+ return (RECALL_STAGES as readonly string[]).includes(value)
15
+ }
16
+
17
+ export const STAGE_LABEL: Record<RecallStage, string> = {
18
+ "plan-start": "Plan 起草",
19
+ "spec-start": "Spec 起草",
20
+ dps: "DPS 门禁",
21
+ rahs: "RAHS 门禁",
22
+ handoff: "Handoff 交接",
23
+ }
24
+
25
+ /**
26
+ * 阶段词模式。按特异性从高到低排列——先命中者为准(handoff/rahs/dps 比 plan 更具体,
27
+ * 避免「交接 plan」被判成 plan-start)。
28
+ */
29
+ const STAGE_PATTERNS: ReadonlyArray<{ stage: RecallStage; re: RegExp }> = [
30
+ { stage: "handoff", re: /handoff|交接(手册|文档|说明)?|交接给/i },
31
+ { stage: "rahs", re: /rahs|注意力漂移|执行健康度/i },
32
+ { stage: "dps", re: /dps|文档质量闸门|质量闸门|门禁(评分|检查)?/i },
33
+ { stage: "spec-start", re: /(生成|写|新建|起草|补)\s*(spec|规格|三元组)|specs?\s*三元组|WHEN-?THEN/i },
34
+ { stage: "plan-start", re: /(生成|写|新建|起草|补)\s*(plan|计划|方案)|plan\s*阶段|规划阶段/i },
35
+ ]
36
+
37
+ /** 识别提示词所属的 ADD 阶段;无法判定返回 null(不猜测) */
38
+ export function detectRecallStage(prompt: string): RecallStage | null {
39
+ if (!prompt) return null
40
+ for (const { stage, re } of STAGE_PATTERNS) {
41
+ if (re.test(prompt)) return stage
42
+ }
43
+ return null
44
+ }
45
+
46
+ export type RecallModeLike = "off" | "shadow" | "inject"
47
+
48
+ /**
49
+ * 阶段召回提示文本(Hook 侧输出)。
50
+ * - off:返回 null(不提示、不召回)
51
+ * - shadow:提示可显式调用,并说明 Hook 不注入
52
+ * - inject:提示调用后将注入上下文
53
+ */
54
+ export function buildStageRecallHint(
55
+ stage: RecallStage,
56
+ mode: RecallModeLike,
57
+ ): string | null {
58
+ if (mode === "off") return null
59
+ const label = STAGE_LABEL[stage]
60
+ const tail =
61
+ mode === "shadow"
62
+ ? "(当前 shadow 模式:Hook 不注入上下文,召回结果需显式消费)"
63
+ : "(当前 inject 模式:调用后上下文将被注入)"
64
+ return (
65
+ `[Memory] 检测到${label}阶段 → 建议调用 ` +
66
+ `recall_memory({ stage: "${stage}", query: <本阶段意图>, planKeyword: <Plan 关键词> }) ` +
67
+ `获取受治理的历史上下文(含来源与评分)。${tail}\n`
68
+ )
69
+ }