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
@@ -138,6 +138,118 @@ export const CollabContractRowSchema = z.looseObject({
138
138
  updatedAt: z.date(),
139
139
  })
140
140
 
141
+ // ===== Agent Memory 行 schema(对齐 prisma/add.prisma,Plan: add-coder-agent-memory-plan-v2) =====
142
+
143
+ export const MemoryKindSchema = z.enum([
144
+ "DECISION", "CONSTRAINT", "PITFALL", "FAILURE", "LESSON",
145
+ "PATTERN", "CONVENTION", "FACT", "HANDOFF_DIGEST", "HYPOTHESIS",
146
+ ])
147
+ export const MemoryStatusSchema = z.enum([
148
+ "CANDIDATE", "PENDING", "ACTIVE", "STALE", "SUPERSEDED", "REJECTED", "ARCHIVED",
149
+ ])
150
+ export const MemoryScopeTypeSchema = z.enum([
151
+ "ORGANIZATION", "REPOSITORY", "BRANCH", "MODULE", "PATH", "SYMBOL", "PLAN", "SPEC",
152
+ ])
153
+ export const MemorySourceTypeSchema = z.enum([
154
+ "PLAN", "SPEC", "DPS_GATE", "DEV_OPERATION", "RAHS_GATE", "HANDOFF", "MANUAL", "IMPORT",
155
+ ])
156
+ export const EmbeddingStateSchema = z.enum(["DISABLED", "PENDING", "READY", "FAILED", "STALE"])
157
+ export const RecallOutcomeSchema = z.enum([
158
+ "UNKNOWN", "USED", "USEFUL", "IRRELEVANT", "OUTDATED", "CONTRADICTED", "HARMFUL",
159
+ ])
160
+
161
+ export const AddMemoryRowSchema = z.looseObject({
162
+ id: z.string(),
163
+ kind: MemoryKindSchema,
164
+ status: MemoryStatusSchema,
165
+ topic: z.string(),
166
+ content: z.string(),
167
+ summary: z.string().nullable(),
168
+ scopeType: MemoryScopeTypeSchema,
169
+ scopeValue: z.string(),
170
+ repositoryRef: z.string(),
171
+ importance: z.number(),
172
+ confidence: z.number(),
173
+ validFrom: z.date(),
174
+ validUntil: z.date().nullable(),
175
+ supersededById: z.string().nullable(),
176
+ contentHash: z.string(),
177
+ embeddingModel: z.string().nullable(),
178
+ embeddingDim: z.number().nullable(),
179
+ embeddingState: EmbeddingStateSchema,
180
+ createdBy: z.string().nullable(),
181
+ approvedBy: z.string().nullable(),
182
+ approvedAt: z.date().nullable(),
183
+ metadata: z.unknown().nullable(),
184
+ createdAt: z.date(),
185
+ updatedAt: z.date(),
186
+ })
187
+
188
+ export const AddMemoryEvidenceRowSchema = z.looseObject({
189
+ id: z.string(),
190
+ repositoryRef: z.string(),
191
+ sourceType: MemorySourceTypeSchema,
192
+ sourceRef: z.string(),
193
+ planKeyword: z.string().nullable(),
194
+ excerpt: z.string(),
195
+ contentHash: z.string(),
196
+ occurredAt: z.date().nullable(),
197
+ metadata: z.unknown().nullable(),
198
+ createdAt: z.date(),
199
+ })
200
+
201
+ export const AddMemoryEvidenceLinkRowSchema = z.looseObject({
202
+ memoryId: z.string(),
203
+ evidenceId: z.string(),
204
+ relation: z.string().nullable(),
205
+ createdAt: z.date(),
206
+ })
207
+
208
+ export const AddMetricSnapshotRowSchema = z.looseObject({
209
+ id: z.string(),
210
+ repositoryRef: z.string(),
211
+ metricType: z.string(),
212
+ value: z.number(),
213
+ baseline: z.number().nullable(),
214
+ delta: z.number().nullable(),
215
+ unit: z.string().nullable(),
216
+ planKeyword: z.string().nullable(),
217
+ specRef: z.string().nullable(),
218
+ commitSha: z.string().nullable(),
219
+ sourceRef: z.string(),
220
+ metadata: z.unknown().nullable(),
221
+ measuredAt: z.date(),
222
+ })
223
+
224
+ export const AddMemoryRecallRowSchema = z.looseObject({
225
+ id: z.string(),
226
+ repositoryRef: z.string(),
227
+ query: z.string(),
228
+ stage: z.string(),
229
+ consumerRef: z.string().nullable(),
230
+ scopeContext: z.unknown(),
231
+ candidateIds: z.unknown(),
232
+ selectedIds: z.unknown(),
233
+ scoreBreakdown: z.unknown(),
234
+ exclusionReasons: z.unknown().nullable(),
235
+ rankingVersion: z.string(),
236
+ tokenBudget: z.number(),
237
+ injectedTokens: z.number(),
238
+ latencyMs: z.number().nullable(),
239
+ degradedMode: z.string().nullable(),
240
+ createdAt: z.date(),
241
+ })
242
+
243
+ export const AddMemoryRecallItemRowSchema = z.looseObject({
244
+ recallId: z.string(),
245
+ memoryId: z.string(),
246
+ selected: z.boolean(),
247
+ rank: z.number().nullable(),
248
+ outcome: RecallOutcomeSchema,
249
+ feedback: z.string().nullable(),
250
+ updatedAt: z.date(),
251
+ })
252
+
141
253
  // ===== 类型派生(单一真源:schema → 类型) =====
142
254
 
143
255
  export type PlanRow = z.infer<typeof PlanRowSchema>
@@ -147,6 +259,12 @@ export type AuditLogRow = z.infer<typeof AuditLogRowSchema>
147
259
  export type DevOperationRow = z.infer<typeof DevOperationRowSchema>
148
260
  export type AddUserRow = z.infer<typeof AddUserRowSchema>
149
261
  export type CollabContractRow = z.infer<typeof CollabContractRowSchema>
262
+ export type AddMemoryRow = z.infer<typeof AddMemoryRowSchema>
263
+ export type AddMemoryEvidenceRow = z.infer<typeof AddMemoryEvidenceRowSchema>
264
+ export type AddMemoryEvidenceLinkRow = z.infer<typeof AddMemoryEvidenceLinkRowSchema>
265
+ export type AddMetricSnapshotRow = z.infer<typeof AddMetricSnapshotRowSchema>
266
+ export type AddMemoryRecallRow = z.infer<typeof AddMemoryRecallRowSchema>
267
+ export type AddMemoryRecallItemRow = z.infer<typeof AddMemoryRecallItemRowSchema>
150
268
 
151
269
  // ===== 查询参数(Prisma 最常用子集,结构化约束 + 运算符支持) =====
152
270
 
@@ -159,6 +277,7 @@ export interface QueryArgs<T> {
159
277
  orderBy?: { [K in keyof T]?: OrderDirection }
160
278
  take?: number
161
279
  skip?: number
280
+ cursor?: Record<string, unknown>
162
281
  include?: Record<string, unknown>
163
282
  }
164
283
 
@@ -0,0 +1,27 @@
1
+ /*
2
+ * create_hitl 的交互裁决(手写模块;**不要放进 hitl-interaction.strategy.ts**——
3
+ * 那是 `hitl-interaction-rules.toml` 的生成产物,任何手写逻辑都会被 generate 覆盖)。
4
+ *
5
+ * 背景(2026-09-14 修复 Codex 空转):
6
+ * 客户端按安装环境裁决交互方式:Qoder=genui、Codex=mcpApps、其余=inputRequired。
7
+ * `update_hitl` 早已有 `mcpApps` 分支(引导走 core widget),但 `create_hitl` **漏了这一支**,
8
+ * 于是 Codex 下走 elicitation 弹框 → 客户端不展示 → 一直"还在要输入" →
9
+ * 连续 8 轮后以 `inputRequired.maxRounds` 失败(实测报 `still required input after 8 rounds`)。
10
+ */
11
+
12
+ /**
13
+ * create_hitl 是否需要跳过"创建确认弹框":
14
+ * - `_fallback` / `_use_genui`:调用方显式声明的无弹框路径(既有语义);
15
+ * - **`mcpApps`(Codex)**:审批交互在 core widget(`render_hitl_approval`)里完成,
16
+ * 此处直接产出 DRAFT 提案,再由 widget 供用户拍板。
17
+ */
18
+ export function shouldSkipHitlCreateDialog(
19
+ mode: string, // 运行期取 toml 裁决值,收窄到 string 边界
20
+ flags: { fallback?: boolean; useGenui?: boolean; mcpApps?: boolean } = {},
21
+ ): boolean {
22
+ /*
23
+ * `_mcp_apps` 显式开关(2026-09-14 自 farm-agent 回灌):环境裁决之外再给调用方一个强制入口——
24
+ * 适配其它客户端/build、或需要确定性地走 widget 流程时不必依赖 toml 探测结果。
25
+ */
26
+ return Boolean(flags.useGenui || flags.fallback || flags.mcpApps) || mode === "mcpApps"
27
+ }
@@ -0,0 +1,110 @@
1
+ /*
2
+ * HITL 提案文档内容(生成 + 裁决回写)
3
+ *
4
+ * 为什么抽出来(2026-09-14 修复"生成器不满足自身 schema"):
5
+ * `create_hitl` 原先把 markdown 直接内联在工具体里拼字符串,产物**缺 `## 审批结论`**,
6
+ * 而真源 `templates/core/templates/hitl-template.md` 与 `hitl-template.schema.json`
7
+ * 都把它列为必需章节;同时 `update_hitl` 只改状态行与维度列,**裁决结论不回写文档**——
8
+ * 结果 6/6 `*.hitl.md` 不满足自身 schema,"文件通道"残缺(结论只在 DB 与哨兵里)。
9
+ * 抽成纯函数后:生成物可用同一校验层直接断言(tests/hitl-proposal-content.test.ts)。
10
+ */
11
+
12
+ /** 真源模板中的必需章节名(改这里必须同时改 hitl-template.md / .schema.json) */
13
+ export const HITL_VERDICT_HEADING = "## 审批结论"
14
+
15
+ export interface HitlDimension {
16
+ name: string
17
+ content?: string
18
+ }
19
+
20
+ export interface BuildHitlProposalInput {
21
+ planName: string
22
+ round: number
23
+ type: string
24
+ createdAt: string
25
+ dimensions?: HitlDimension[]
26
+ }
27
+
28
+ const escapeCell = (value: string) => value.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>")
29
+
30
+ /** 生成 HITL 提案 markdown(与 `hitl-template.md` 章节对齐) */
31
+ export function buildHitlProposalMarkdown(input: BuildHitlProposalInput): string {
32
+ const dims = input.dimensions ?? []
33
+ const tableRows = dims.length > 0
34
+ ? dims.map((d, i) => `| ${i + 1} | ${d.name} | ${escapeCell(d.content ?? "")} | 同意/驳回 |`).join("\n")
35
+ : [
36
+ "| 1 | 实施主体 | | 同意/驳回 |",
37
+ "| 2 | 数据模型 | | 同意/驳回 |",
38
+ "| 3 | MCP 工具 | | 同意/驳回 |",
39
+ "| 4 | 文件命名 | | 同意/驳回 |",
40
+ "| 5 | 模板 + schema | | 同意/驳回 |",
41
+ "| 6 | 新增依赖 | | 同意/驳回 |",
42
+ "| 7 | 预计文件数 | | 同意/驳回 |",
43
+ "| 8 | 预计轮次 | | 同意/驳回 |",
44
+ ].join("\n")
45
+
46
+ return [
47
+ `# ${input.planName} — HITL 提案 (round ${input.round})`,
48
+ "",
49
+ `> 创建: ${input.createdAt} | 类型: ${input.type} | 状态: DRAFT`,
50
+ "",
51
+ "## HITL 计划总览",
52
+ "",
53
+ "请填写以下决策维度,人工审核后点击 update_hitl 弹框选择「同意/驳回」完成审批:",
54
+ "",
55
+ "| # | 维度 | 方案内容 | 决策 |",
56
+ "|---|------|----------|:----:|",
57
+ tableRows,
58
+ "",
59
+ HITL_VERDICT_HEADING,
60
+ "",
61
+ "> **tongyi**:方案通过。",
62
+ "> **bohui**:方案驳回,需修正后重新 create_hitl 发起下一轮。",
63
+ "",
64
+ "| 时间 | 决策 | 原因 |",
65
+ "|------|:----:|------|",
66
+ "| | | |",
67
+ "",
68
+ ].join("\n")
69
+ }
70
+
71
+ export interface HitlVerdict {
72
+ status: string
73
+ at: string
74
+ reason?: string
75
+ }
76
+
77
+ /** 结论表骨架(DRAFT 状态下为空白行;与 buildHitlProposalMarkdown 产出同形) */
78
+ export const HITL_VERDICT_TABLE = [
79
+ "| 时间 | 决策 | 原因 |",
80
+ "|------|:----:|------|",
81
+ ] as const
82
+
83
+ /** 保证「审批结论」章节存在(历史产物/旧生成器产物补骨架,不改动已有结论行) */
84
+ export function ensureHitlVerdictSection(content: string): string {
85
+ if (content.includes(HITL_VERDICT_HEADING)) return content
86
+ return content.trimEnd() + "\n\n" + [HITL_VERDICT_HEADING, "", ...HITL_VERDICT_TABLE, "| | | |", ""].join("\n")
87
+ }
88
+
89
+ /**
90
+ * 把裁决回写进提案:刷新状态行 + 在「审批结论」表写入(或覆盖)一行。
91
+ * 幂等:同一提案重复调用只保留最后一行,不追加重复行。
92
+ */
93
+ export function applyHitlDecisionToProposal(content: string, verdict: HitlVerdict): string {
94
+ const withStatus = ensureHitlVerdictSection(content).replace(/(状态:\s*)[A-Z_]+/, `$1${verdict.status}`)
95
+ const row = `| ${verdict.at} | ${verdict.status} | ${escapeCell(verdict.reason ?? "")} |`
96
+ const idx = withStatus.indexOf(HITL_VERDICT_HEADING)
97
+ const freshSection = [HITL_VERDICT_HEADING, "", "| 时间 | 决策 | 原因 |", "|------|:----:|------|", row, ""].join("\n")
98
+ if (idx < 0) {
99
+ // 历史产物(生成器修复前)没有该章节 → 补章节再写行,保证"文件通道"完整
100
+ return withStatus.trimEnd() + "\n\n" + freshSection
101
+ }
102
+ const lines = withStatus.slice(idx).split("\n")
103
+ const headerIdx = lines.findIndex((l) => l.startsWith("| 时间 |"))
104
+ if (headerIdx < 0) return withStatus.slice(0, idx) + freshSection
105
+ // 保留到分隔行,替换全部数据行;其后非表格内容(如追加说明)原样保留
106
+ const kept = lines.slice(headerIdx + 2).filter((l) => !l.trim().startsWith("|"))
107
+ while (kept.length > 0 && kept[0].trim() === "") kept.shift()
108
+ const head = withStatus.slice(0, idx) // 标题/元信息/维度表等「审批结论」之前的内容
109
+ return head + [...lines.slice(0, headerIdx + 2), row, ...(kept.length > 0 ? ["", ...kept] : [])].join("\n")
110
+ }
@@ -0,0 +1,85 @@
1
+ /*
2
+ * HITL 审批实例 HTML(Plan hitl-widget-runtime-gap §WidgetInstance / §RenderFallback)
3
+ *
4
+ * 为什么需要:MCP Apps 类客户端(Codex)才渲染 widget;其余环境(Qoder/弹框/无 UI)下
5
+ * 审批必须仍有确定性入口。本模块把 core widget 模板 + 本次提案的维度数据落成一份**可直接打开**的
6
+ * 实例 HTML(文件面板/浏览器均可),并把路径回给调用方(render_hitl_approval 的 fallback)。
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
9
+ import { join } from "node:path"
10
+
11
+ export const HITL_INSTANCE_DIR = "hitl"
12
+
13
+ export interface HitlInstanceInput {
14
+ planName: string
15
+ type: string
16
+ round: number
17
+ status: string
18
+ dimensions: { name: string; content: string }[]
19
+ templateHtml: string
20
+ }
21
+
22
+ function escapeHtml(value: string): string {
23
+ return value
24
+ .replace(/&/g, "&amp;")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;")
28
+ }
29
+
30
+ /** 生成实例 HTML:在模板 </body> 前注入 JSON 载荷 + 人类可读维度表(模板无占位符,故用注入而非替换) */
31
+ export function buildHitlInstanceHtml(input: HitlInstanceInput): string {
32
+ const payload = {
33
+ planName: input.planName,
34
+ type: input.type,
35
+ round: input.round,
36
+ status: input.status,
37
+ dimensions: input.dimensions,
38
+ }
39
+ const rows = input.dimensions
40
+ .map(
41
+ (d, i) =>
42
+ `<tr><td>${i + 1}</td><td>${escapeHtml(d.name)}</td><td>${escapeHtml(d.content)}</td><td>同意 / 驳回</td></tr>`,
43
+ )
44
+ .join("")
45
+ const injected = [
46
+ `<script id="hitl-instance-payload" type="application/json">${JSON.stringify(payload).replace(/</g, "\\u003c")}</script>`,
47
+ `<section id="hitl-fallback-panel" data-plan="${escapeHtml(input.planName)}" data-round="${input.round}">`,
48
+ `<h2>HITL 审批(${escapeHtml(input.planName)} · ${escapeHtml(input.type)} round ${input.round})</h2>`,
49
+ `<p>状态:${escapeHtml(input.status)} · 共 ${input.dimensions.length} 个维度。逐项确认后,把裁决结果告知 AI 即可落库(本页面为只读降级入口,不直接改库)。</p>`,
50
+ `<table><thead><tr><th>#</th><th>维度</th><th>方案内容</th><th>决策</th></tr></thead><tbody>${rows}</tbody></table>`,
51
+ `</section>`,
52
+ ].join("\n")
53
+
54
+ const bodyClose = input.templateHtml.match(/<\/body>/i)
55
+ if (bodyClose && bodyClose.index !== undefined) {
56
+ return (
57
+ input.templateHtml.slice(0, bodyClose.index) + injected + "\n" + input.templateHtml.slice(bodyClose.index)
58
+ )
59
+ }
60
+ return `${input.templateHtml}\n${injected}\n`
61
+ }
62
+
63
+ export interface WriteHitlInstanceResult {
64
+ htmlPath: string
65
+ created: boolean
66
+ }
67
+
68
+ /**
69
+ * 落盘实例 HTML(幂等:同 plan+round 覆盖写)。
70
+ * 模板缺失 → 抛明确错误(禁止静默返回空 HTML)。
71
+ */
72
+ export function writeHitlInstanceHtml(
73
+ input: Omit<HitlInstanceInput, "templateHtml"> & { projectRoot: string; magicDir: string },
74
+ ): WriteHitlInstanceResult {
75
+ const templatePath = join(input.projectRoot, input.magicDir, "templates", "hitl-approval-widget.html")
76
+ if (!existsSync(templatePath)) {
77
+ throw new Error(`HITL widget 模板缺失: ${templatePath}(请执行 add-coder sync)`)
78
+ }
79
+ const dir = join(input.projectRoot, input.magicDir, HITL_INSTANCE_DIR)
80
+ mkdirSync(dir, { recursive: true })
81
+ const htmlPath = join(dir, `${input.planName}-round${input.round}.html`)
82
+ const html = buildHitlInstanceHtml({ ...input, templateHtml: readFileSync(templatePath, "utf-8") })
83
+ writeFileSync(htmlPath, html, "utf-8")
84
+ return { htmlPath, created: true }
85
+ }
@@ -0,0 +1,250 @@
1
+ /*
2
+ * 冷启动批量拟合(Plan rank-calibration Task 2.1 / Spec §2 §BatchFit)
3
+ *
4
+ * 三工具分工里"**从无到有**"的那一件:给定一批带 ground truth 的样本 → 输出一个**点估计**权重。
5
+ * 不做在线跟踪(那是 Kalman),不做频谱诊断(那是 FFT)。
6
+ *
7
+ * 两个关键约束:
8
+ * 1. **train/test 切分**:标注集派生的样本必须按 query 维度切分,拟合只用 train,
9
+ * 验收只在 test —— 用验收集拟合会虚高 MRR(Spec §2 [2026-09-13 修订]);
10
+ * 2. **目标函数可注入**:本模块默认用内置代理目标(按通道位次加权的命中率),
11
+ * 真实校准由 `rank-calibrate.ts` 注入"跑 recallPipeline + 算 MRR"的目标函数。
12
+ * 这样模块保持纯函数可测,而线上用真指标而非代理指标。
13
+ */
14
+ import { createHash } from "node:crypto"
15
+ import type { FeedbackChannel } from "./feedback-stats.js"
16
+
17
+ export const DEFAULT_MIN_SAMPLES = 5
18
+
19
+ /** 现行排序常数(无快照时的回落值 = 改造前行为,必须保持一致) */
20
+ export const DEFAULT_WEIGHTS_V3 = {
21
+ vectorWeight: 0.3,
22
+ boostScale: 1,
23
+ rrfK: 10,
24
+ } as const
25
+
26
+ export interface FitWeights {
27
+ vectorWeight: number
28
+ boostScale: number
29
+ rrfK: number
30
+ }
31
+
32
+ /** 一条样本:query 归属 + ground truth + 各通道位次 */
33
+ export interface FitSample {
34
+ queryId: string
35
+ memoryId: string
36
+ relevant: boolean
37
+ /** 该条在各通道中的位次(未命中该通道则不出现) */
38
+ ranks: Partial<Record<FeedbackChannel, number>>
39
+ }
40
+
41
+ export interface FitSplit {
42
+ splitId: string
43
+ trainQueries: string[]
44
+ testQueries: string[]
45
+ }
46
+
47
+ export interface WeightFitResult {
48
+ weights: FitWeights
49
+ /** 1 − 目标函数最优值(目标函数应返回 0..1 的归一化收益) */
50
+ residual: number
51
+ samples: number
52
+ trainSamples: number
53
+ testSamples: number
54
+ method: "default" | "grid"
55
+ splitId: string
56
+ /** 最优解落在搜索空间边界(提示空间可能过窄) */
57
+ boundaryHit: boolean
58
+ }
59
+
60
+ export interface SearchSpace {
61
+ vectorWeight: number[]
62
+ boostScale: number[]
63
+ rrfK: number[]
64
+ }
65
+
66
+ export const DEFAULT_SEARCH_SPACE: SearchSpace = {
67
+ vectorWeight: [0, 0.1, 0.2, 0.3, 0.5, 0.8],
68
+ boostScale: [0.5, 1, 2],
69
+ rrfK: [5, 10, 20, 60],
70
+ }
71
+
72
+ /** 目标函数:返回 0..1 的归一化收益(越大越好),由调用方决定是代理指标还是真实 MRR */
73
+ export type FitObjective = (weights: FitWeights, samples: readonly FitSample[]) => number
74
+
75
+ /**
76
+ * 切分分组 = **Plan 业务闭包**(架构事实,非算法聚类)。
77
+ * 见 Spec §2.2:单元素自身迭代(单元内)与跨单元流转(跨单元)是两条不同数据流,
78
+ * 用临时聚类代替 Plan 闭包会让"防泄漏"退化成口径游戏。
79
+ */
80
+ export interface QueryGroup {
81
+ /** = planKeyword(业务闭包标识) */
82
+ groupId: string
83
+ queryIds: string[]
84
+ /** 单元状态:未收敛单元的样本默认不进训练集(Spec §2.2) */
85
+ unitState?: "closed" | "in-flight"
86
+ }
87
+
88
+ export interface GroupFold {
89
+ testGroupId: string
90
+ trainGroups: string[]
91
+ testQueries: string[]
92
+ trainQueries: string[]
93
+ }
94
+
95
+ export interface GroupedSplit {
96
+ mode: "leave-one-group-out" | "single-holdout"
97
+ folds: GroupFold[]
98
+ splitId: string
99
+ /** 簇数 < 3 → 结论不稳(明示,不隐瞒) */
100
+ lowGroupCount: boolean
101
+ }
102
+
103
+ function hashId(input: string): string {
104
+ return createHash("sha256").update(input).digest("hex").slice(0, 8)
105
+ }
106
+
107
+ /**
108
+ * 按 query 维度确定性切分(同输入 → 同 split,可复现)。
109
+ * 切分单位是 **query** 而非样本:同一 query 的样本不能跨集,否则信息泄漏。
110
+ */
111
+ export function splitByQuery(queryIds: readonly string[], opts: { testRatio?: number } = {}): FitSplit {
112
+ const testRatio = opts.testRatio ?? 0.4
113
+ const unique = [...new Set(queryIds)]
114
+ // 以 queryId 哈希排序 → 与传入顺序无关,可复现
115
+ const ordered = [...unique].sort((a, b) => hashId(a).localeCompare(hashId(b)))
116
+ const testCount = Math.max(1, Math.round(ordered.length * testRatio))
117
+ const testQueries = ordered.slice(0, Math.min(testCount, Math.max(0, ordered.length - 1)))
118
+ const testSet = new Set(testQueries)
119
+ const trainQueries = ordered.filter((q) => !testSet.has(q))
120
+ return {
121
+ splitId: hashId(`split|${ordered.join(",")}|${testRatio}`),
122
+ trainQueries,
123
+ testQueries,
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 按**业务闭包**切分(Spec §2.1/§2.2)。
129
+ *
130
+ * - 默认 `leave-one-group-out`:逐簇留出作 test、其余作 train(每折的 test query 一条都不在 train 中)
131
+ * - `single-holdout`:按簇哈希确定性挑 test 簇(用于簇数不足时的退化路径)
132
+ * - 未收敛单元(`unitState:"in-flight"`)参与分组但**默认不进训练集**,避免把"进行中的偏差"学进权重
133
+ */
134
+ export function splitByGroup(
135
+ groups: readonly QueryGroup[],
136
+ opts: { mode?: "leave-one-group-out" | "single-holdout"; testRatio?: number; includeInFlight?: boolean } = {},
137
+ ): GroupedSplit {
138
+ const usable = groups.filter((g) => opts.includeInFlight === true || (g.unitState ?? "closed") === "closed")
139
+ const all = usable.length > 0 ? usable : groups
140
+ const mode = opts.mode ?? "leave-one-group-out"
141
+ const splitId = hashId(`groupsplit|${mode}|${all.map((g) => g.groupId).sort().join(",")}`)
142
+ const lowGroupCount = all.length < 3
143
+
144
+ if (mode === "single-holdout") {
145
+ const testRatio = opts.testRatio ?? 0.4
146
+ const ordered = [...all].sort((a, b) => hashId(a.groupId).localeCompare(hashId(b.groupId)))
147
+ const testCount = Math.max(1, Math.min(ordered.length - 1, Math.round(ordered.length * testRatio)))
148
+ const testGroups = ordered.slice(0, Math.max(0, testCount))
149
+ const trainGroups = ordered.slice(testGroups.length)
150
+ return {
151
+ mode,
152
+ splitId,
153
+ lowGroupCount,
154
+ folds: [
155
+ {
156
+ testGroupId: testGroups.map((g) => g.groupId).join("+"),
157
+ trainGroups: trainGroups.map((g) => g.groupId),
158
+ testQueries: testGroups.flatMap((g) => g.queryIds),
159
+ trainQueries: trainGroups.flatMap((g) => g.queryIds),
160
+ },
161
+ ],
162
+ }
163
+ }
164
+
165
+ return {
166
+ mode,
167
+ splitId,
168
+ lowGroupCount,
169
+ folds: all.map((test) => ({
170
+ testGroupId: test.groupId,
171
+ trainGroups: all.filter((g) => g.groupId !== test.groupId).map((g) => g.groupId),
172
+ testQueries: [...test.queryIds],
173
+ trainQueries: all.filter((g) => g.groupId !== test.groupId).flatMap((g) => g.queryIds),
174
+ })),
175
+ }
176
+ }
177
+
178
+ /** 内置代理目标:按通道位次加权命中率(AA 于 recall 的粗略替代,仅用于无注入场景与测试) */
179
+ export function surrogateObjective(weights: FitWeights, samples: readonly FitSample[]): number {
180
+ if (samples.length === 0) return 0
181
+ let good = 0
182
+ let total = 0
183
+ for (const s of samples) {
184
+ const lexical = s.ranks.lexical != null ? (1 - weights.vectorWeight) / (weights.rrfK + s.ranks.lexical) : 0
185
+ const vector = s.ranks.vector != null ? weights.vectorWeight / (weights.rrfK + s.ranks.vector) : 0
186
+ const score = lexical + vector
187
+ total += 1
188
+ if (s.relevant && score > 0) good += 1
189
+ else if (!s.relevant && score === 0) good += 1
190
+ }
191
+ return total === 0 ? 0 : Number((good / total).toFixed(6))
192
+ }
193
+
194
+ export function fitWeights(
195
+ samples: readonly FitSample[],
196
+ split: FitSplit,
197
+ opts: { minSamples?: number; space?: SearchSpace; objective?: FitObjective } = {},
198
+ ): WeightFitResult {
199
+ const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES
200
+ const trainSet = new Set(split.trainQueries)
201
+ const testSet = new Set(split.testQueries)
202
+ const train = samples.filter((s) => trainSet.has(s.queryId))
203
+ const test = samples.filter((s) => testSet.has(s.queryId))
204
+
205
+ const base = {
206
+ samples: samples.length,
207
+ trainSamples: train.length,
208
+ testSamples: test.length,
209
+ splitId: split.splitId,
210
+ }
211
+
212
+ // 冷启动门控:样本不足 → 回落现行常数,且**不产出"拟合结果"**
213
+ if (train.length < minSamples) {
214
+ return { ...base, weights: { ...DEFAULT_WEIGHTS_V3 }, residual: 1, method: "default", boundaryHit: false }
215
+ }
216
+
217
+ const space = opts.space ?? DEFAULT_SEARCH_SPACE
218
+ const objective = opts.objective ?? surrogateObjective
219
+ let best: FitWeights = { ...DEFAULT_WEIGHTS_V3 }
220
+ let bestScore = -Infinity
221
+ let hit = false
222
+
223
+ for (const vectorWeight of space.vectorWeight) {
224
+ for (const boostScale of space.boostScale) {
225
+ for (const rrfK of space.rrfK) {
226
+ const weights: FitWeights = { vectorWeight, boostScale, rrfK }
227
+ const score = objective(weights, train)
228
+ if (score > bestScore) {
229
+ bestScore = score
230
+ best = weights
231
+ hit =
232
+ vectorWeight === space.vectorWeight[0] ||
233
+ vectorWeight === space.vectorWeight[space.vectorWeight.length - 1] ||
234
+ boostScale === space.boostScale[0] ||
235
+ boostScale === space.boostScale[space.boostScale.length - 1] ||
236
+ rrfK === space.rrfK[0] ||
237
+ rrfK === space.rrfK[space.rrfK.length - 1]
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ return {
244
+ ...base,
245
+ weights: best,
246
+ residual: Number((1 - Math.max(0, Math.min(1, bestScore))).toFixed(6)),
247
+ method: "grid",
248
+ boundaryHit: hit,
249
+ }
250
+ }