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,654 @@
1
+ /*
2
+ * Memory 治理面 MCP 工具(Spec §8,Plan §6.1/§6.2)
3
+ *
4
+ * 8 个 MVP 工具:
5
+ * propose_memory / recall_memory / get_memory / list_memories
6
+ * review_memory / resolve_memory / feedback_memory / get_memory_health
7
+ *
8
+ * 横切契约:
9
+ * - 所有写工具与读工具均强制 repositoryRef === runtimeContext.projectKey(ERR_REPOSITORY_MISMATCH)
10
+ * - 错误一律稳定错误码(domain/errors.ts),不返回自由文本堆栈
11
+ * - 召回降级必须 degradedMode 明示
12
+ * - 状态迁移走领域状态机 assertTransition + AuditLog 打点(ADD-5)
13
+ */
14
+ import * as z from "zod/v4"
15
+ import type { ToolRegistrar } from "./registrar.js"
16
+ import { textResponse, errorResponse } from "../shared/response.js"
17
+ import { prisma } from "../shared/prisma.js"
18
+ import { DATABASE_URL, getRuntimeContext } from "../shared/env.js"
19
+ import {
20
+ validatedDelegate,
21
+ AddMemoryRowSchema,
22
+ AddMemoryEvidenceRowSchema,
23
+ AddMemoryEvidenceLinkRowSchema,
24
+ AddMemoryRecallRowSchema,
25
+ AddMemoryRecallItemRowSchema,
26
+ AuditLogRowSchema,
27
+ AddUserRowSchema,
28
+ type AddMemoryRow,
29
+ type AddMemoryEvidenceRow,
30
+ type AddMemoryEvidenceLinkRow,
31
+ type AddMemoryRecallRow,
32
+ type AddMemoryRecallItemRow,
33
+ type AuditLogRow,
34
+ type AddUserRow,
35
+ } from "../shared/db-types.js"
36
+ import { MemoryError, isMemoryError } from "../shared/memory/domain/errors.js"
37
+ import { assertTransition, type MemoryAction, type MemoryStatus } from "../shared/memory/domain/state-machine.js"
38
+ import { assertScopeWritable, scopesCompatible, type ScopeContext, type MemoryScopeType } from "../shared/memory/domain/scope.js"
39
+ import { contentHash } from "../shared/memory/domain/dedup.js"
40
+ import { detectConflicts } from "../shared/memory/domain/conflicts.js"
41
+ import { assertNoSecrets } from "../shared/memory/domain/secrets.js"
42
+ import { recallPipeline } from "../shared/memory/retrieval/pipeline.js"
43
+ import type { LexicalSearchAdapter, RawQuerier, RecallFilter } from "../shared/memory/retrieval/types.js"
44
+ import { createPgFtsAdapter } from "../shared/memory/retrieval/fts/pg.js"
45
+ import { createSqliteFtsAdapter } from "../shared/memory/retrieval/fts/sqlite.js"
46
+ import {
47
+ createNoneEmbeddingProvider,
48
+ createEmbeddingProviderFromConfig,
49
+ parseEmbeddingEnv,
50
+ type EmbeddingProvider,
51
+ type VectorSearchAdapter,
52
+ } from "../shared/memory/embedding/index.js"
53
+ import { createPgVectorAdapter } from "../shared/memory/retrieval/vector/pgvector.js"
54
+ import { createSqliteVecAdapter } from "../shared/memory/retrieval/vector/sqlite-vec.js"
55
+
56
+ /** 测试可注入依赖(生产默认从 prisma/DATABASE_URL 构建) */
57
+ export interface MemoryToolDeps {
58
+ lexical?: LexicalSearchAdapter[]
59
+ rawQuerier?: RawQuerier
60
+ embedding?: EmbeddingProvider
61
+ vector?: VectorSearchAdapter | null
62
+ }
63
+
64
+ const MEMORY_KINDS = ["DECISION", "CONSTRAINT", "PITFALL", "FAILURE", "LESSON", "PATTERN", "CONVENTION", "FACT", "HANDOFF_DIGEST", "HYPOTHESIS"] as const
65
+ const SCOPE_TYPES = ["ORGANIZATION", "REPOSITORY", "BRANCH", "MODULE", "PATH", "SYMBOL", "PLAN", "SPEC"] as const
66
+ const RECALL_OUTCOMES = ["UNKNOWN", "USED", "USEFUL", "IRRELEVANT", "OUTDATED", "CONTRADICTED", "HARMFUL"] as const
67
+
68
+ export function registerMemoryTools(server: ToolRegistrar, deps: MemoryToolDeps = {}) {
69
+ const runtimeContext = getRuntimeContext()
70
+
71
+ // 无类型边界单点(zod 托管):动态 client → 运行期校验的泛型委托
72
+ const memoryDb = validatedDelegate<AddMemoryRow>(prisma.addMemory, AddMemoryRowSchema, "AddMemory")
73
+ const evidenceDb = validatedDelegate<AddMemoryEvidenceRow>(prisma.addMemoryEvidence, AddMemoryEvidenceRowSchema, "AddMemoryEvidence")
74
+ const linkDb = validatedDelegate<AddMemoryEvidenceLinkRow>(prisma.addMemoryEvidenceLink, AddMemoryEvidenceLinkRowSchema, "AddMemoryEvidenceLink")
75
+ const recallDb = validatedDelegate<AddMemoryRecallRow>(prisma.addMemoryRecall, AddMemoryRecallRowSchema, "AddMemoryRecall")
76
+ const recallItemDb = validatedDelegate<AddMemoryRecallItemRow>(prisma.addMemoryRecallItem, AddMemoryRecallItemRowSchema, "AddMemoryRecallItem")
77
+ const auditDb = validatedDelegate<AuditLogRow>(prisma.auditLog, AuditLogRowSchema, "AuditLog")
78
+ const userDb = validatedDelegate<AddUserRow>(prisma.addUser, AddUserRowSchema, "AddUser")
79
+
80
+ const rawQuerier: RawQuerier = deps.rawQuerier ?? {
81
+ query: <T = Record<string, unknown>>(sql: string, params: unknown[]): Promise<T[]> =>
82
+ (prisma.$queryRawUnsafe as unknown as (s: string, ...p: unknown[]) => Promise<unknown>)(sql, ...params) as Promise<T[]>,
83
+ }
84
+ const lexical: LexicalSearchAdapter[] = deps.lexical ?? [
85
+ DATABASE_URL.startsWith("postgres") ? createPgFtsAdapter(rawQuerier) : createSqliteFtsAdapter(rawQuerier),
86
+ ]
87
+ // ── 轮 3:按配置装配 provider / vector adapter(默认 none + 不启用向量 = 首版行为)──
88
+ // 惰性解析:只在真正召回时构建,避免启动期加载 ONNX/网络依赖;任一环节不可用即降级。
89
+ interface RuntimePair { embedding: EmbeddingProvider; vector: VectorSearchAdapter | null }
90
+ let cachedRuntime: Promise<RuntimePair> | null = null
91
+
92
+ function resolveRuntimePair(): Promise<RuntimePair> {
93
+ if (deps.embedding || deps.vector !== undefined) {
94
+ return Promise.resolve({
95
+ embedding: deps.embedding ?? createNoneEmbeddingProvider(),
96
+ vector: deps.vector ?? null,
97
+ })
98
+ }
99
+ if (!cachedRuntime) cachedRuntime = buildRuntimePairFromEnv()
100
+ return cachedRuntime
101
+ }
102
+
103
+ async function buildRuntimePairFromEnv(): Promise<RuntimePair> {
104
+ const cfg = parseEmbeddingEnv()
105
+ let embedding: EmbeddingProvider
106
+ try {
107
+ embedding = await createEmbeddingProviderFromConfig(cfg)
108
+ } catch {
109
+ // 配置要求向量但 provider 不可用 → 合法降级为 none(degradedMode 明示)
110
+ embedding = createNoneEmbeddingProvider()
111
+ }
112
+ const mode = (process.env.ADD_MEMORY_VECTOR ?? "off").toLowerCase()
113
+ if (mode === "off" || embedding.id === "none") return { embedding, vector: null }
114
+
115
+ const model = cfg.model ?? (embedding.id === "local-onnx" ? "Xenova/bge-small-zh-v1.5" : "text-embedding-3-small")
116
+ const dimension = embedding.dimension || cfg.dimension || 0
117
+ if (dimension <= 0) return { embedding, vector: null }
118
+
119
+ const adapter = DATABASE_URL.startsWith("postgres")
120
+ ? createPgVectorAdapter({ querier: rawQuerier, dimension, model })
121
+ : createSqliteVecAdapter({ querier: rawQuerier, dimension, model })
122
+ const health = await adapter.health()
123
+ // 能力不足(扩展缺失/表缺失)→ 不启用向量,召回侧以 degradedMode 明示
124
+ return { embedding, vector: health.status === "ok" ? adapter : null }
125
+ }
126
+
127
+ // ── 横切守卫 ──
128
+
129
+ /** repository 边界:不一致即拒(越权测试的唯一判定) */
130
+ function assertRepository(repositoryRef: string): void {
131
+ if (repositoryRef !== runtimeContext.projectKey) {
132
+ throw new MemoryError("ERR_REPOSITORY_MISMATCH", `期望 ${runtimeContext.projectKey},实收 ${repositoryRef}`)
133
+ }
134
+ }
135
+
136
+ function assertInvariant01(field: string, v: number | undefined): void {
137
+ if (v !== undefined && (Number.isNaN(v) || v < 0 || v > 1)) {
138
+ throw new MemoryError("ERR_INVARIANT", `${field}=${v} 越出 [0,1]`)
139
+ }
140
+ }
141
+
142
+ /** 统一错误出口:MemoryError 透传稳定码;其余兜底为 ERR_INVARIANT 之外的通用包装 */
143
+ function fail(e: unknown) {
144
+ if (isMemoryError(e)) return errorResponse(e.message)
145
+ return errorResponse(`ERR_INTERNAL: ${e instanceof Error ? e.message : String(e)}`)
146
+ }
147
+
148
+ async function ensureSystemUser(): Promise<string> {
149
+ let u = await userDb.findUnique({ where: { username: "ai-assistant" }, select: { id: true } })
150
+ if (!u) u = await userDb.create({ data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" } })
151
+ return u.id
152
+ }
153
+
154
+ /** 状态迁移审计打点(ADD-5:不落库的状态迁移视为未发生) */
155
+ async function writeTransitionAudit(input: {
156
+ memoryId: string; action: string; from: MemoryStatus; to: MemoryStatus; reason?: string; actor?: string
157
+ }): Promise<string> {
158
+ const userId = await ensureSystemUser()
159
+ const log = await auditDb.create({
160
+ data: {
161
+ userId,
162
+ projectKey: runtimeContext.projectKey,
163
+ producerAdapterKey: runtimeContext.adapterKey,
164
+ contextId: runtimeContext.contextId,
165
+ action: `MEMORY_${input.action.toUpperCase()}`,
166
+ targetType: "AddMemory",
167
+ targetId: input.memoryId,
168
+ beforeState: { status: input.from },
169
+ afterState: { status: input.to, actor: input.actor ?? null },
170
+ reason: input.reason ?? null,
171
+ },
172
+ })
173
+ return log.id
174
+ }
175
+
176
+ /** 取行 + 边界校验(找不到/越库 → 稳定错误码) */
177
+ async function getScopedMemory(memoryId: string): Promise<AddMemoryRow> {
178
+ const row = await memoryDb.findUnique({ where: { id: memoryId } })
179
+ if (!row) throw new MemoryError("ERR_NOT_FOUND", `memoryId=${memoryId}`)
180
+ if (row.repositoryRef !== runtimeContext.projectKey) {
181
+ throw new MemoryError("ERR_REPOSITORY_MISMATCH", `memoryId=${memoryId} 属于 ${row.repositoryRef}`)
182
+ }
183
+ return row
184
+ }
185
+
186
+ // ===== 1. propose_memory =====
187
+ server.registerTool("propose_memory", {
188
+ description: "提出记忆候选(CANDIDATE,绝不直接 ACTIVE)。执行去重(幂等键 repositoryRef+contentHash+scope)、密钥扫描(命中拒写)、同 scope 冲突检测(返回 conflicts 由人工裁决)。可关联证据引用。",
189
+ inputSchema: z.object({
190
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
191
+ kind: z.enum(MEMORY_KINDS).describe("记忆类型"),
192
+ topic: z.string().describe("主题(短句)"),
193
+ content: z.string().describe("记忆正文(可验证结论,禁止只存数值)"),
194
+ scopeType: z.enum(SCOPE_TYPES).describe("scope 类型"),
195
+ scopeValue: z.string().describe("scope 值(如仓库名/分支/路径/符号)"),
196
+ summary: z.string().optional().describe("可选摘要(注入上下文时优先使用)"),
197
+ importance: z.number().optional().describe("重要性 [0,1],默认 0.5"),
198
+ confidence: z.number().optional().describe("置信度 [0,1],默认 0.5"),
199
+ evidenceRefs: z.array(z.string()).optional().describe("证据引用列表(sourceRef,如 plan 路径/审计 ID/文件路径)"),
200
+ sourceType: z.enum(["PLAN", "SPEC", "DPS_GATE", "DEV_OPERATION", "RAHS_GATE", "HANDOFF", "MANUAL", "IMPORT"]).optional().default("MANUAL"),
201
+ createdBy: z.string().optional().describe("提议者标识"),
202
+ }),
203
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
204
+ try {
205
+ const repositoryRef = args.repositoryRef as string
206
+ assertRepository(repositoryRef)
207
+ const kind = args.kind as string
208
+ const topic = args.topic as string
209
+ const content = args.content as string
210
+ const scopeType = args.scopeType as MemoryScopeType
211
+ const scopeValue = args.scopeValue as string
212
+ assertScopeWritable({ type: scopeType, value: scopeValue })
213
+ assertInvariant01("importance", args.importance as number | undefined)
214
+ assertInvariant01("confidence", args.confidence as number | undefined)
215
+ assertNoSecrets(`${topic}\n${content}`)
216
+
217
+ const hash = contentHash(content)
218
+ // 幂等去重:同幂等键且未被拒绝/归档 → 合并语义,返回已有 id
219
+ const existing = await memoryDb.findFirst({
220
+ where: { repositoryRef, contentHash: hash, scopeType, scopeValue, status: { notIn: ["REJECTED", "ARCHIVED"] } },
221
+ })
222
+ if (existing) {
223
+ return textResponse(JSON.stringify({ memoryId: existing.id, merged: true, status: existing.status, conflicts: [] }))
224
+ }
225
+
226
+ // 冲突检测:同 scope 的 ACTIVE 高相似 CONSTRAINT/DECISION/CONVENTION
227
+ const actives = await memoryDb.findMany({
228
+ where: { repositoryRef, status: "ACTIVE", scopeType, scopeValue },
229
+ take: 200,
230
+ })
231
+ const conflicts = detectConflicts({ kind, topic, content, scopeType, scopeValue }, actives)
232
+
233
+ const created = await memoryDb.create({
234
+ data: {
235
+ kind, topic, content,
236
+ summary: (args.summary as string | undefined) ?? null,
237
+ scopeType, scopeValue, repositoryRef,
238
+ importance: (args.importance as number | undefined) ?? 0.5,
239
+ confidence: (args.confidence as number | undefined) ?? 0.5,
240
+ contentHash: hash,
241
+ createdBy: (args.createdBy as string | undefined) ?? null,
242
+ } as Partial<AddMemoryRow>,
243
+ })
244
+
245
+ // 证据关联(幂等:repositoryRef+sourceType+sourceRef+contentHash 唯一)
246
+ const evidenceRefs = (args.evidenceRefs as string[] | undefined) ?? []
247
+ const sourceType = (args.sourceType as string | undefined) ?? "MANUAL"
248
+ let evidenceCount = 0
249
+ for (const ref of evidenceRefs) {
250
+ assertNoSecrets(ref)
251
+ const ev = await evidenceDb.upsert({
252
+ where: {
253
+ repositoryRef_sourceType_sourceRef_contentHash: {
254
+ repositoryRef, sourceType, sourceRef: ref, contentHash: contentHash(ref),
255
+ },
256
+ },
257
+ create: {
258
+ repositoryRef, sourceType, sourceRef: ref,
259
+ excerpt: ref.slice(0, 500),
260
+ contentHash: contentHash(ref),
261
+ } as Partial<AddMemoryEvidenceRow>,
262
+ update: {},
263
+ })
264
+ const linkKey = { memoryId: created.id, evidenceId: ev.id }
265
+ const linked = await linkDb.findFirst({ where: linkKey })
266
+ if (!linked) await linkDb.create({ data: linkKey })
267
+ evidenceCount++
268
+ }
269
+
270
+ return textResponse(JSON.stringify({
271
+ memoryId: created.id, merged: false, status: created.status, evidenceCount, conflicts,
272
+ }))
273
+ } catch (e) { return fail(e) }
274
+ })
275
+
276
+ // ===== 2. recall_memory =====
277
+ server.registerTool("recall_memory", {
278
+ description: "受约束的混合召回(FTS-first,RRF 融合 + 治理重排 + token 预算裁剪)。返回结构化 items(含 whySelected/scoreBreakdown/supersedes)+ recallId(审计可重放)+ degradedMode(降级明示)。",
279
+ inputSchema: z.object({
280
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
281
+ query: z.string().describe("召回意图(自然语言查询)"),
282
+ stage: z.string().describe("召回阶段(如 session-start/plan-start/dps/rah/handoff/prompt)"),
283
+ branch: z.string().optional(),
284
+ module: z.string().optional(),
285
+ paths: z.array(z.string()).optional().describe("当前上下文路径集合"),
286
+ symbols: z.array(z.string()).optional().describe("当前上下文符号集合"),
287
+ planKeyword: z.string().optional(),
288
+ specRef: z.string().optional(),
289
+ includePlanScope: z.boolean().optional().describe("显式历史查询时放行 PLAN/SPEC scope"),
290
+ kinds: z.array(z.enum(MEMORY_KINDS)).optional().describe("限定记忆类型"),
291
+ maxTokens: z.number().optional().default(1200).describe("注入 token 预算"),
292
+ limit: z.number().optional().default(20).describe("候选上限"),
293
+ consumerRef: z.string().optional().describe("消费方标识(审计用)"),
294
+ diagnostic: z.boolean().optional().describe("诊断模式:放行 STALE(带警告)"),
295
+ }),
296
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
297
+ try {
298
+ const repositoryRef = args.repositoryRef as string
299
+ assertRepository(repositoryRef)
300
+
301
+ // 轮 3:按配置解析 provider / vector adapter(惰性;默认 none + 不启用向量 = 首版行为)
302
+ const { embedding, vector } = await resolveRuntimePair()
303
+
304
+ const scopeCtx: ScopeContext = {
305
+ repository: repositoryRef,
306
+ branch: args.branch as string | undefined,
307
+ module: args.module as string | undefined,
308
+ paths: args.paths as string[] | undefined,
309
+ symbols: args.symbols as string[] | undefined,
310
+ planKeyword: args.planKeyword as string | undefined,
311
+ specRef: args.specRef as string | undefined,
312
+ includePlanScope: (args.includePlanScope as boolean | undefined) ?? false,
313
+ }
314
+
315
+ // degradedMode 明示:向量能力缺失 → FTS-only(合法降级,非故障)
316
+ const vectorHealth = vector ? await vector.health() : null
317
+ const degradedMode = vectorHealth && vectorHealth.status === "ok"
318
+ ? null
319
+ : `fts-only(embedding=${embedding.id}${vector ? `,vector=${vectorHealth?.status ?? "unknown"}` : ""})`
320
+
321
+ // 组合式 vector 候选:query → embedding → 向量索引(仅当 provider+adapter 均健康)
322
+ const vectorForPipeline =
323
+ vector && vectorHealth && vectorHealth.status === "ok"
324
+ ? {
325
+ search: async (query: string, filter: RecallFilter, limit: number) => {
326
+ const [vec] = await embedding.embed([query])
327
+ return vector.search(vec, filter, limit)
328
+ },
329
+ }
330
+ : null
331
+
332
+ const result = await recallPipeline(
333
+ {
334
+ query: args.query as string,
335
+ stage: args.stage as string,
336
+ repositoryRef,
337
+ scopeCtx,
338
+ maxTokens: (args.maxTokens as number | undefined) ?? 1200,
339
+ kinds: args.kinds as string[] | undefined,
340
+ limit: (args.limit as number | undefined) ?? 20,
341
+ consumerRef: (args.consumerRef as string | undefined) ?? "mcp:recall_memory",
342
+ diagnostic: (args.diagnostic as boolean | undefined) ?? false,
343
+ },
344
+ {
345
+ lexical,
346
+ vector: vectorForPipeline,
347
+ fetchByIds: async (ids) =>
348
+ await memoryDb.findMany({
349
+ where: { id: { in: ids } },
350
+ include: { supersedes: { select: { id: true } } },
351
+ }),
352
+ fetchEvidenceSourceRefs: async (memoryIds) => {
353
+ const links = await linkDb.findMany({ where: { memoryId: { in: memoryIds } } })
354
+ const evIds = [...new Set(links.map((l) => l.evidenceId))]
355
+ const evs = evIds.length > 0 ? await evidenceDb.findMany({ where: { id: { in: evIds } } }) : []
356
+ const refById = new Map(evs.map((e) => [e.id, e.sourceRef]))
357
+ const out = new Map<string, string[]>()
358
+ for (const l of links) {
359
+ const ref = refById.get(l.evidenceId)
360
+ if (!ref) continue
361
+ const arr = out.get(l.memoryId) ?? []
362
+ arr.push(ref)
363
+ out.set(l.memoryId, arr)
364
+ }
365
+ return out
366
+ },
367
+ audit: {
368
+ createRecall: (data) => recallDb.create({ data: data }),
369
+ createRecallItem: (data) => recallItemDb.create({ data: data }),
370
+ },
371
+ degradedMode: degradedMode ?? undefined,
372
+ },
373
+ )
374
+
375
+ return textResponse(JSON.stringify(result))
376
+ } catch (e) { return fail(e) }
377
+ })
378
+
379
+ // ===== 3. get_memory =====
380
+ server.registerTool("get_memory", {
381
+ description: "查看单条记忆详情与 provenance:evidence 列表、supersession 链(前驱/后继)、最近召回使用情况。",
382
+ inputSchema: z.object({
383
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
384
+ memoryId: z.string(),
385
+ }),
386
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
387
+ try {
388
+ assertRepository(args.repositoryRef as string)
389
+ const row = await getScopedMemory(args.memoryId as string)
390
+
391
+ const links = await linkDb.findMany({ where: { memoryId: row.id } })
392
+ const evIds = links.map((l) => l.evidenceId)
393
+ const evidence = evIds.length > 0 ? await evidenceDb.findMany({ where: { id: { in: evIds } } }) : []
394
+
395
+ // supersession 链:向上(supersededById)+ 向下(谁 supersede 了我)
396
+ // 注意:validatedDelegate 会按行 schema 校验返回值,不能用 select 裁剪字段
397
+ const supersededBy = row.supersededById
398
+ ? await memoryDb.findUnique({ where: { id: row.supersededById } })
399
+ : null
400
+ const supersedes = await memoryDb.findMany({ where: { supersededById: row.id } })
401
+
402
+ // 最近召回使用(RecallItem join Recall 元信息)
403
+ const items = await recallItemDb.findMany({
404
+ where: { memoryId: row.id }, orderBy: { updatedAt: "desc" }, take: 10,
405
+ })
406
+ const recallIds = [...new Set(items.map((i) => i.recallId))]
407
+ const recalls = recallIds.length > 0 ? await recallDb.findMany({ where: { id: { in: recallIds } } }) : []
408
+ const recallById = new Map(recalls.map((r) => [r.id, r]))
409
+ const recallUsage = items.map((i) => ({
410
+ recallId: i.recallId, selected: i.selected, rank: i.rank, outcome: i.outcome,
411
+ query: recallById.get(i.recallId)?.query ?? null,
412
+ stage: recallById.get(i.recallId)?.stage ?? null,
413
+ at: recallById.get(i.recallId)?.createdAt ?? null,
414
+ }))
415
+
416
+ return textResponse(JSON.stringify({
417
+ memory: row,
418
+ evidence: evidence.map((e) => ({ id: e.id, sourceType: e.sourceType, sourceRef: e.sourceRef, excerpt: e.excerpt, occurredAt: e.occurredAt })),
419
+ supersession: {
420
+ supersededBy: supersededBy?.id ?? null,
421
+ supersedes: supersedes.map((s) => s.id),
422
+ },
423
+ recallUsage,
424
+ }))
425
+ } catch (e) { return fail(e) }
426
+ })
427
+
428
+ // ===== 4. list_memories =====
429
+ server.registerTool("list_memories", {
430
+ description: "按状态/scope/kind 列表查询(cursor 分页,createdAt 倒序)。",
431
+ inputSchema: z.object({
432
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
433
+ status: z.enum(["CANDIDATE", "PENDING", "ACTIVE", "STALE", "SUPERSEDED", "REJECTED", "ARCHIVED"]).optional(),
434
+ kind: z.enum(MEMORY_KINDS).optional(),
435
+ scopeType: z.enum(SCOPE_TYPES).optional(),
436
+ scopeValue: z.string().optional(),
437
+ cursor: z.string().optional().describe("上一页最后一行的 id"),
438
+ limit: z.number().optional().default(20).describe("每页条数,默认 20,最大 100"),
439
+ }),
440
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
441
+ try {
442
+ const repositoryRef = args.repositoryRef as string
443
+ assertRepository(repositoryRef)
444
+ const limit = Math.min((args.limit as number | undefined) ?? 20, 100)
445
+ const cursor = args.cursor as string | undefined
446
+
447
+ const where: Record<string, unknown> = { repositoryRef }
448
+ if (args.status) where.status = args.status
449
+ if (args.kind) where.kind = args.kind
450
+ if (args.scopeType) where.scopeType = args.scopeType
451
+ if (args.scopeValue) where.scopeValue = args.scopeValue
452
+
453
+ const rows = await memoryDb.findMany({
454
+ where,
455
+ orderBy: { createdAt: "desc" },
456
+ take: limit,
457
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
458
+ })
459
+ const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null
460
+ return textResponse(JSON.stringify({
461
+ items: rows.map((r) => ({
462
+ id: r.id, kind: r.kind, status: r.status, topic: r.topic,
463
+ scope: { type: r.scopeType, value: r.scopeValue },
464
+ importance: r.importance, confidence: r.confidence,
465
+ validUntil: r.validUntil, createdAt: r.createdAt,
466
+ })),
467
+ nextCursor,
468
+ }))
469
+ } catch (e) { return fail(e) }
470
+ })
471
+
472
+ // ===== 5. review_memory =====
473
+ server.registerTool("review_memory", {
474
+ description: "获取待审核治理队列:CANDIDATE/PENDING 候选 + 各自证据 + 重复项(同 contentHash)+ 疑似冲突(同 scope 高相似 ACTIVE)。",
475
+ inputSchema: z.object({
476
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
477
+ kind: z.enum(MEMORY_KINDS).optional(),
478
+ limit: z.number().optional().default(50),
479
+ }),
480
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
481
+ try {
482
+ const repositoryRef = args.repositoryRef as string
483
+ assertRepository(repositoryRef)
484
+ const limit = Math.min((args.limit as number | undefined) ?? 50, 200)
485
+
486
+ const where: Record<string, unknown> = { repositoryRef, status: { in: ["CANDIDATE", "PENDING"] } }
487
+ if (args.kind) where.kind = args.kind
488
+ const candidates = await memoryDb.findMany({ where, orderBy: { createdAt: "asc" }, take: limit })
489
+
490
+ const actives = await memoryDb.findMany({ where: { repositoryRef, status: "ACTIVE" }, take: 500 })
491
+ const result = []
492
+ for (const c of candidates) {
493
+ const links = await linkDb.findMany({ where: { memoryId: c.id } })
494
+ const evIds = links.map((l) => l.evidenceId)
495
+ const evidence = evIds.length > 0 ? await evidenceDb.findMany({ where: { id: { in: evIds } } }) : []
496
+ const duplicates = await memoryDb.findMany({
497
+ where: { repositoryRef, contentHash: c.contentHash, id: { not: c.id }, status: { notIn: ["REJECTED", "ARCHIVED"] } },
498
+ take: 10,
499
+ })
500
+ const conflicts = detectConflicts(c, actives)
501
+ result.push({
502
+ id: c.id, kind: c.kind, status: c.status, topic: c.topic,
503
+ scope: { type: c.scopeType, value: c.scopeValue },
504
+ evidence: evidence.map((e) => ({ sourceType: e.sourceType, sourceRef: e.sourceRef })),
505
+ duplicates: duplicates.map((d) => ({ id: d.id, status: d.status, topic: d.topic })),
506
+ conflicts,
507
+ })
508
+ }
509
+ return textResponse(JSON.stringify({ backlog: result.length, candidates: result }))
510
+ } catch (e) { return fail(e) }
511
+ })
512
+
513
+ // ===== 6. resolve_memory =====
514
+ server.registerTool("resolve_memory", {
515
+ description: "执行治理状态迁移(状态机强制校验 + AuditLog 打点)。action: submit_review|approve|reject|stale|supersede|archive|restore。approve 必须已有 ≥1 证据且提供 actor;supersede 必须提供 supersededById(新记忆 id)且 scope 兼容。",
516
+ inputSchema: z.object({
517
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
518
+ memoryId: z.string(),
519
+ action: z.enum(["submit_review", "approve", "reject", "stale", "supersede", "archive", "restore"]),
520
+ reason: z.string().optional(),
521
+ actor: z.string().optional().describe("操作者(approve 时作为 approvedBy,必填)"),
522
+ supersededById: z.string().optional().describe("supersede 专用:新记忆 id"),
523
+ }),
524
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
525
+ try {
526
+ assertRepository(args.repositoryRef as string)
527
+ const memoryId = args.memoryId as string
528
+ const action = args.action as string
529
+ const actor = args.actor as string | undefined
530
+ const row = await getScopedMemory(memoryId)
531
+ const current = row.status
532
+ const domainAction: MemoryAction = action === "stale" ? "mark_stale" : (action as MemoryAction)
533
+
534
+ // 迁移上下文:按动作装配不变量证据
535
+ const tctx: Parameters<typeof assertTransition>[2] = {}
536
+ if (domainAction === "approve") {
537
+ const links = await linkDb.findMany({ where: { memoryId } })
538
+ tctx.evidenceCount = links.length
539
+ tctx.approvedBy = actor ?? null
540
+ tctx.approvedAt = actor ? new Date() : null
541
+ }
542
+ let superseding: AddMemoryRow | null = null
543
+ if (domainAction === "supersede") {
544
+ const newId = args.supersededById as string | undefined
545
+ if (newId) {
546
+ superseding = await memoryDb.findUnique({ where: { id: newId } })
547
+ tctx.supersededById = newId
548
+ tctx.supersessionCompatible = !!superseding &&
549
+ superseding.repositoryRef === row.repositoryRef &&
550
+ scopesCompatible(
551
+ { type: row.scopeType, value: row.scopeValue },
552
+ { type: superseding.scopeType, value: superseding.scopeValue },
553
+ )
554
+ }
555
+ }
556
+
557
+ const t = assertTransition(current, domainAction, tctx)
558
+
559
+ const data: Record<string, unknown> = { status: t.to }
560
+ if (domainAction === "approve") { data.approvedBy = actor; data.approvedAt = new Date() }
561
+ if (domainAction === "supersede") data.supersededById = args.supersededById
562
+ await memoryDb.update({ where: { id: memoryId }, data: data })
563
+
564
+ const auditRef = await writeTransitionAudit({
565
+ memoryId, action: `TRANSITION_${action.toUpperCase()}`,
566
+ from: t.from, to: t.to,
567
+ reason: args.reason as string | undefined, actor,
568
+ })
569
+
570
+ return textResponse(JSON.stringify({ memoryId, oldStatus: t.from, newStatus: t.to, auditRef }))
571
+ } catch (e) { return fail(e) }
572
+ })
573
+
574
+ // ===== 7. feedback_memory =====
575
+ server.registerTool("feedback_memory", {
576
+ description: "记录召回结果反馈(幂等 upsert RecallItem.outcome)。outcome: UNKNOWN|USED|USEFUL|IRRELEVANT|OUTDATED|CONTRADICTED|HARMFUL。",
577
+ inputSchema: z.object({
578
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
579
+ recallId: z.string(),
580
+ memoryId: z.string(),
581
+ outcome: z.enum(RECALL_OUTCOMES),
582
+ feedback: z.string().optional(),
583
+ }),
584
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
585
+ try {
586
+ assertRepository(args.repositoryRef as string)
587
+ const recallId = args.recallId as string
588
+ const memoryId = args.memoryId as string
589
+
590
+ const recall = await recallDb.findUnique({ where: { id: recallId } })
591
+ if (!recall) throw new MemoryError("ERR_NOT_FOUND", `recallId=${recallId}`)
592
+ if (recall.repositoryRef !== runtimeContext.projectKey) {
593
+ throw new MemoryError("ERR_REPOSITORY_MISMATCH", `recallId=${recallId} 属于 ${recall.repositoryRef}`)
594
+ }
595
+ const mem = await memoryDb.findUnique({ where: { id: memoryId } })
596
+ if (!mem) throw new MemoryError("ERR_NOT_FOUND", `memoryId=${memoryId}`)
597
+
598
+ const outcome = args.outcome as string
599
+ const feedback = (args.feedback as string | undefined) ?? null
600
+ await recallItemDb.upsert({
601
+ where: { recallId_memoryId: { recallId, memoryId } },
602
+ create: { recallId, memoryId, selected: false, rank: null, outcome, feedback } as Partial<AddMemoryRecallItemRow>,
603
+ update: { outcome, feedback } as Partial<AddMemoryRecallItemRow>,
604
+ })
605
+ return textResponse(JSON.stringify({ updated: true, recallId, memoryId, outcome }))
606
+ } catch (e) { return fail(e) }
607
+ })
608
+
609
+ // ===== 8. get_memory_health =====
610
+ server.registerTool("get_memory_health", {
611
+ description: "运行与治理健康度:backlog(CANDIDATE/PENDING 计数)、leakage 抽查(近 20 次召回选中项的越库计数)、embedding provider 与 FTS 索引健康。",
612
+ inputSchema: z.object({
613
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
614
+ }),
615
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
616
+ try {
617
+ const repositoryRef = args.repositoryRef as string
618
+ assertRepository(repositoryRef)
619
+ const { embedding } = await resolveRuntimePair()
620
+
621
+ // backlog:raw count(validatedDelegate 不支持 count/select 裁剪)
622
+ const backlogRows = await rawQuerier.query<{ status: string; count: number | string }>(
623
+ `SELECT "status"::text AS status, COUNT(*)::int AS count FROM "AddMemory" WHERE "repositoryRef" = $1 GROUP BY "status"`,
624
+ [repositoryRef],
625
+ )
626
+ const backlog: Record<string, number> = {}
627
+ for (const r of backlogRows) backlog[r.status] = Number(r.count)
628
+
629
+ // leakage 抽查:最近 20 次召回的 selectedIds 是否全部属于本仓库
630
+ const recentRecalls = await recallDb.findMany({
631
+ where: { repositoryRef }, orderBy: { createdAt: "desc" }, take: 20,
632
+ })
633
+ const selectedIds = [...new Set(recentRecalls.flatMap((r) => (r.selectedIds as string[] | null) ?? []))]
634
+ let leakage = 0
635
+ if (selectedIds.length > 0) {
636
+ const memRows = await memoryDb.findMany({ where: { id: { in: selectedIds } } })
637
+ leakage = memRows.filter((m) => m.repositoryRef !== repositoryRef).length
638
+ }
639
+
640
+ const [embeddingHealth, ftsHealth] = await Promise.all([
641
+ embedding.health(),
642
+ ...lexical.map((l) => l.health()),
643
+ ])
644
+
645
+ return textResponse(JSON.stringify({
646
+ repositoryRef,
647
+ backlog: { candidate: backlog.CANDIDATE ?? 0, pending: backlog.PENDING ?? 0, byStatus: backlog },
648
+ leakage: { checkedRecalls: recentRecalls.length, crossRepositorySelections: leakage },
649
+ providers: { embedding: embeddingHealth },
650
+ index: ftsHealth ?? { component: "fts", status: "unavailable", detail: "无 lexical adapter" },
651
+ }))
652
+ } catch (e) { return fail(e) }
653
+ })
654
+ }
@@ -8,6 +8,8 @@ import { prisma } from "../shared/prisma.js"
8
8
  import type { PlanRow } from "../shared/db-types.js"
9
9
  import { PlanRowSchema, validatedDelegate } from "../shared/db-types.js"
10
10
  import { getRuntimeContext } from "../shared/env.js"
11
+ // 口径单一真源:checklist 的 [T]/[R] 统计直接复用校验层,避免 tracker 与校验器两套口径漂移
12
+ import { checklistStats } from "../../../validation/validators/checklist.js"
11
13
  import { assertPathInRuntimeScope } from "../shared/runtime-context.js"
12
14
  import { resolvePlanStatus } from "../shared/plan-lifecycle.js"
13
15
  import { createPrismaPlanStatusStore } from "../shared/plan-status-store.js"
@@ -104,9 +106,12 @@ export function registerPlanTools(server: ToolRegistrar) {
104
106
  let checklistT = 0, checklistTDone = 0, checklistR = 0
105
107
  const checklistContent = await readFileSafe(checklistPath)
106
108
  if (checklistContent) {
107
- checklistT = (checklistContent.match(/\[T\]/g) || []).length
108
- checklistTDone = (checklistContent.match(/\[x\].*\[T\]/g) || []).length
109
- checklistR = (checklistContent.match(/\[R\]/g) || []).length
109
+ // 单一真源:复用校验层 checklistStats(旧实现按全文出现次数统计,把正文里提到的
110
+ // `[T]` 也计入分母 —— 实测 16/16 被报成 17/18,2026-09-14 修正)
111
+ const stats = checklistStats(checklistContent)
112
+ checklistT = stats.tTotal
113
+ checklistTDone = stats.tDone
114
+ checklistR = stats.rTotal
110
115
  }
111
116
  // 定位 add-route
112
117
  const planPrefix = basename(t.name).replace(/-plan-v\d+$/, "")