add-coder 0.3.33 → 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 (98) hide show
  1. package/dist/index.js +21 -3
  2. package/package.json +3 -3
  3. package/templates/.add-coder-src-hash.json +95 -41
  4. package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -84
  5. package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -1
  6. package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -0
  7. package/templates/adapters/claude/hooks/session-start.mjs +65 -1
  8. package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -84
  9. package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -1
  10. package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -0
  11. package/templates/adapters/codex/hooks/session-start.mjs +65 -1
  12. package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -84
  13. package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -1
  14. package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -0
  15. package/templates/adapters/qoder/hooks/session-start.mjs +67 -1
  16. package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -84
  17. package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -1
  18. package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -0
  19. package/templates/adapters/trae/hooks/session-start.mjs +65 -1
  20. package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -84
  21. package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -1
  22. package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -0
  23. package/templates/adapters/vscode/hooks/session-start.mjs +65 -1
  24. package/templates/core/governance/doc-format-guard.ts +29 -112
  25. package/templates/core/governance/post-tool-router.ts +33 -1
  26. package/templates/core/governance/prompt-router.ts +47 -0
  27. package/templates/core/governance/session-start-guard.ts +48 -1
  28. package/templates/core/prisma/add.prisma +203 -0
  29. package/templates/core/scripts/db-ensure.sh +61 -2
  30. package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
  31. package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
  32. package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
  33. package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
  34. package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
  35. package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
  36. package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
  37. package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
  38. package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
  39. package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
  40. package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
  41. package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
  42. package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
  43. package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
  44. package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
  45. package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
  46. package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
  47. package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
  48. package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
  49. package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
  50. package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
  51. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
  52. package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
  53. package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
  54. package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
  55. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
  56. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
  57. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
  58. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
  59. package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
  60. package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
  61. package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
  62. package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
  63. package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
  64. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
  65. package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
  66. package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
  67. package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
  68. package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
  69. package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +41 -3
  70. package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
  71. package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +2 -2
  72. package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
  73. package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
  74. package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
  75. package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
  76. package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
  77. package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
  78. package/templates/core/scripts/mcp-server.ts +36 -0
  79. package/templates/core/templates/checklist-template.md +13 -0
  80. package/templates/core/templates/review-implementation-template.md +24 -0
  81. package/templates/core/templates/review-template.md +16 -0
  82. package/templates/core/validation/index.ts +136 -0
  83. package/templates/core/validation/policy.ts +91 -0
  84. package/templates/core/validation/registry.ts +61 -0
  85. package/templates/core/validation/schema-validator.ts +277 -0
  86. package/templates/core/validation/validators/add-route.ts +32 -0
  87. package/templates/core/validation/validators/checklist.ts +48 -0
  88. package/templates/core/validation/validators/handoff.ts +46 -0
  89. package/templates/core/validation/validators/hitl.ts +22 -0
  90. package/templates/core/validation/validators/index.ts +52 -0
  91. package/templates/core/validation/validators/plan.ts +20 -0
  92. package/templates/core/validation/validators/report.ts +16 -0
  93. package/templates/core/validation/validators/review.ts +30 -0
  94. package/templates/core/validation/validators/spec.ts +25 -0
  95. package/templates/core/validation/validators/tasks.ts +40 -0
  96. package/templates/core/validation/validators/types.ts +32 -0
  97. package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
  98. package/templates/core/scripts/mcp-server/shared/dps-spec-ref.ts +0 -17
@@ -0,0 +1,226 @@
1
+ /*
2
+ * Consolidation 异步任务(Spec §10,Plan §9.2 Post-Handoff 行)
3
+ *
4
+ * 职责(全部 fail-open 隔离:单步失败不影响他步,错误入报告):
5
+ * 1. drainEvidenceQueue —— 采证队列落库
6
+ * 2. 重复检测 —— 同 repositoryRef+contentHash 多行 → 报告(不自动合并,人审)
7
+ * 3. 冲突队列 —— CANDIDATE/PENDING 与 ACTIVE 高相似 → 报告(不自动激活,Plan §3)
8
+ * 4. 指标候选 —— AddMetricSnapshot 连续异常 streak 命中 → 创建 CANDIDATE(幂等)
9
+ * 5. Handoff Digest —— HANDOFF Evidence → HANDOFF_DIGEST Candidate(幂等,绝不直接 ACTIVE)
10
+ * 6. 刷新 L1 快照 —— 供 session-start Hook 注入
11
+ */
12
+ import type {
13
+ AddMemoryEvidenceRow,
14
+ AddMemoryEvidenceLinkRow,
15
+ AddMemoryRow,
16
+ AddMetricSnapshotRow,
17
+ TableDelegate,
18
+ } from "../../db-types.js"
19
+ import { detectConflicts, type Conflict } from "../domain/conflicts.js"
20
+ import { contentHash } from "../domain/dedup.js"
21
+ import { detectAnomalyStreak, type MetricPoint } from "../domain/metric-candidate.js"
22
+ import { buildHandoffDigest } from "../domain/handoff-digest.js"
23
+ import { drainEvidenceQueue, type DrainResult } from "./evidence-collector.js"
24
+ import { refreshL1Snapshot, type SnapshotDeps, type SnapshotResult } from "./snapshot.js"
25
+
26
+ export interface ConsolidationDeps extends SnapshotDeps {
27
+ memoryDb: TableDelegate<AddMemoryRow>
28
+ evidenceDb: Pick<TableDelegate<AddMemoryEvidenceRow>, "upsert" | "findMany">
29
+ linkDb: Pick<TableDelegate<AddMemoryEvidenceLinkRow>, "findFirst" | "create">
30
+ metricDb: TableDelegate<AddMetricSnapshotRow>
31
+ }
32
+
33
+ export interface ConsolidationReport {
34
+ drain: DrainResult
35
+ duplicates: { contentHash: string; ids: string[] }[]
36
+ conflictQueue: { candidateId: string; topic: string; conflicts: Conflict[] }[]
37
+ metricCandidatesCreated: string[]
38
+ handoffDigestsCreated: string[]
39
+ snapshot: SnapshotResult | null
40
+ errors: string[]
41
+ }
42
+
43
+ export async function runConsolidation(deps: ConsolidationDeps): Promise<ConsolidationReport> {
44
+ const report: ConsolidationReport = {
45
+ drain: { processed: 0, skipped: 0, errors: [], newOffset: 0 },
46
+ duplicates: [],
47
+ conflictQueue: [],
48
+ metricCandidatesCreated: [],
49
+ handoffDigestsCreated: [],
50
+ snapshot: null,
51
+ errors: [],
52
+ }
53
+
54
+ // 1. 采证队列落库
55
+ try {
56
+ report.drain = await drainEvidenceQueue(deps)
57
+ } catch (e) {
58
+ report.errors.push(`drain: ${e instanceof Error ? e.message : String(e)}`)
59
+ }
60
+
61
+ // 2/3. 重复检测 + 冲突队列(共享一次全量读取)
62
+ try {
63
+ const rows = await deps.memoryDb.findMany({
64
+ where: { repositoryRef: deps.repositoryRef, status: { notIn: ["REJECTED", "ARCHIVED"] } },
65
+ take: 500,
66
+ })
67
+ const byHash = new Map<string, string[]>()
68
+ for (const r of rows) {
69
+ const arr = byHash.get(r.contentHash) ?? []
70
+ arr.push(r.id)
71
+ byHash.set(r.contentHash, arr)
72
+ }
73
+ for (const [hash, ids] of byHash) {
74
+ if (ids.length > 1) report.duplicates.push({ contentHash: hash, ids })
75
+ }
76
+ const actives = rows.filter((r) => r.status === "ACTIVE")
77
+ for (const c of rows.filter((r) => r.status === "CANDIDATE" || r.status === "PENDING")) {
78
+ const conflicts = detectConflicts(c, actives)
79
+ if (conflicts.length > 0) report.conflictQueue.push({ candidateId: c.id, topic: c.topic, conflicts })
80
+ }
81
+ } catch (e) {
82
+ report.errors.push(`dedup/conflicts: ${e instanceof Error ? e.message : String(e)}`)
83
+ }
84
+
85
+ // 4. 指标候选(Plan §10:连续异常 streak 才生成,内容必须是可验证结论)
86
+ try {
87
+ const metrics = await deps.metricDb.findMany({
88
+ where: { repositoryRef: deps.repositoryRef },
89
+ orderBy: { measuredAt: "asc" },
90
+ take: 500,
91
+ })
92
+ const byType = new Map<string, MetricPoint[]>()
93
+ for (const m of metrics) {
94
+ const arr = byType.get(m.metricType) ?? []
95
+ arr.push({
96
+ repositoryRef: m.repositoryRef,
97
+ metricType: m.metricType,
98
+ value: m.value,
99
+ baseline: m.baseline,
100
+ planKeyword: m.planKeyword,
101
+ measuredAt: m.measuredAt,
102
+ sourceRef: m.sourceRef,
103
+ })
104
+ byType.set(m.metricType, arr)
105
+ }
106
+ for (const points of byType.values()) {
107
+ const proposal = detectAnomalyStreak(points)
108
+ if (!proposal) continue
109
+ const hash = contentHash(proposal.content)
110
+ const existing = await deps.memoryDb.findFirst({
111
+ where: {
112
+ repositoryRef: deps.repositoryRef,
113
+ contentHash: hash,
114
+ scopeType: "REPOSITORY",
115
+ scopeValue: deps.repositoryRef,
116
+ status: { notIn: ["REJECTED", "ARCHIVED"] },
117
+ },
118
+ })
119
+ if (existing) continue // 幂等:已提过同结论
120
+ const created = await deps.memoryDb.create({
121
+ data: {
122
+ kind: "PATTERN",
123
+ topic: proposal.topic,
124
+ content: proposal.content,
125
+ scopeType: "REPOSITORY",
126
+ scopeValue: deps.repositoryRef,
127
+ repositoryRef: deps.repositoryRef,
128
+ contentHash: hash,
129
+ createdBy: "memory-jobs:consolidation",
130
+ },
131
+ })
132
+ for (const ref of proposal.evidenceSourceRefs) {
133
+ const ev = await deps.evidenceDb.upsert({
134
+ where: {
135
+ repositoryRef_sourceType_sourceRef_contentHash: {
136
+ repositoryRef: deps.repositoryRef, sourceType: "DPS_GATE", sourceRef: ref, contentHash: contentHash(ref),
137
+ },
138
+ },
139
+ create: {
140
+ repositoryRef: deps.repositoryRef, sourceType: "DPS_GATE", sourceRef: ref,
141
+ excerpt: ref.slice(0, 500), contentHash: contentHash(ref),
142
+ },
143
+ update: {},
144
+ })
145
+ const linked = await deps.linkDb.findFirst({ where: { memoryId: created.id, evidenceId: ev.id } })
146
+ if (!linked) await deps.linkDb.create({ data: { memoryId: created.id, evidenceId: ev.id } })
147
+ }
148
+ report.metricCandidatesCreated.push(created.id)
149
+ }
150
+ } catch (e) {
151
+ report.errors.push(`metric-candidates: ${e instanceof Error ? e.message : String(e)}`)
152
+ }
153
+
154
+ // 5. Handoff Digest(Plan §3.1 轮 2):HANDOFF Evidence → HANDOFF_DIGEST Candidate
155
+ // 幂等去重靠 dedupKey 派生的 contentHash;绝不直接置 ACTIVE(只产 CANDIDATE)
156
+ try {
157
+ const handoffEvidence = await deps.evidenceDb.findMany({
158
+ where: { repositoryRef: deps.repositoryRef, sourceType: "HANDOFF" },
159
+ orderBy: { occurredAt: "asc" },
160
+ take: 200,
161
+ })
162
+ for (const ev of handoffEvidence) {
163
+ const proposal = buildHandoffDigest(
164
+ { handoffRef: ev.sourceRef, excerpt: ev.excerpt, planKeyword: ev.planKeyword },
165
+ { repositoryRef: deps.repositoryRef },
166
+ )
167
+ const existing = await deps.memoryDb.findFirst({
168
+ where: {
169
+ repositoryRef: deps.repositoryRef,
170
+ contentHash: proposal.contentHash,
171
+ scopeType: proposal.scopeType,
172
+ scopeValue: proposal.scopeValue,
173
+ status: { notIn: ["REJECTED", "ARCHIVED"] },
174
+ },
175
+ })
176
+ if (existing) {
177
+ // 幂等:同一交接 evidence 的摘要已入队过 → 仅补齐证据关联
178
+ const linkedAlready = await deps.linkDb.findFirst({
179
+ where: { memoryId: existing.id, evidenceId: ev.id },
180
+ })
181
+ if (!linkedAlready) {
182
+ await deps.linkDb.create({
183
+ data: { memoryId: existing.id, evidenceId: ev.id },
184
+ })
185
+ }
186
+ continue
187
+ }
188
+ const created = await deps.memoryDb.create({
189
+ data: {
190
+ kind: proposal.kind,
191
+ status: "CANDIDATE", // 绝不直接 ACTIVE
192
+ topic: proposal.topic,
193
+ content: proposal.content,
194
+ scopeType: proposal.scopeType,
195
+ scopeValue: proposal.scopeValue,
196
+ repositoryRef: deps.repositoryRef,
197
+ contentHash: proposal.contentHash,
198
+ importance: 0.6,
199
+ confidence: 0.5,
200
+ createdBy: "memory-jobs:consolidation",
201
+ metadata: { ...proposal.metadata, dedupKey: proposal.dedupKey, evidenceId: ev.id },
202
+ },
203
+ })
204
+ const linked = await deps.linkDb.findFirst({
205
+ where: { memoryId: created.id, evidenceId: ev.id },
206
+ })
207
+ if (!linked) {
208
+ await deps.linkDb.create({
209
+ data: { memoryId: created.id, evidenceId: ev.id },
210
+ })
211
+ }
212
+ report.handoffDigestsCreated.push(created.id)
213
+ }
214
+ } catch (e) {
215
+ report.errors.push(`handoff-digest: ${e instanceof Error ? e.message : String(e)}`)
216
+ }
217
+
218
+ // 6. 刷新 L1 快照
219
+ try {
220
+ report.snapshot = await refreshL1Snapshot(deps)
221
+ } catch (e) {
222
+ report.errors.push(`snapshot: ${e instanceof Error ? e.message : String(e)}`)
223
+ }
224
+
225
+ return report
226
+ }
@@ -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 }