add-coder 0.3.38 → 0.3.40

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 (78) hide show
  1. package/README.en.md +16 -3
  2. package/README.md +16 -3
  3. package/dist/index.js +421 -70
  4. package/package.json +3 -2
  5. package/templates/.add-coder-src-hash.json +74 -61
  6. package/templates/adapters/claude/hooks/post-tool-use.mjs +1 -1
  7. package/templates/adapters/claude/hooks/prompt-submit.mjs +1 -1
  8. package/templates/adapters/claude/hooks/session-start.mjs +25 -5
  9. package/templates/adapters/claude/hooks/stop-check.mjs +2 -2
  10. package/templates/adapters/codex/hooks/post-tool-use.mjs +1 -1
  11. package/templates/adapters/codex/hooks/prompt-submit.mjs +1 -1
  12. package/templates/adapters/codex/hooks/session-start.mjs +25 -5
  13. package/templates/adapters/codex/hooks/stop-check.mjs +2 -2
  14. package/templates/adapters/qoder/hooks/post-tool-use.mjs +1 -1
  15. package/templates/adapters/qoder/hooks/prompt-submit.mjs +1 -1
  16. package/templates/adapters/qoder/hooks/session-start.mjs +25 -5
  17. package/templates/adapters/qoder/hooks/stop-check.mjs +2 -2
  18. package/templates/adapters/trae/hooks/post-tool-use.mjs +1 -1
  19. package/templates/adapters/trae/hooks/prompt-submit.mjs +1 -1
  20. package/templates/adapters/trae/hooks/session-start.mjs +25 -5
  21. package/templates/adapters/trae/hooks/stop-check.mjs +2 -2
  22. package/templates/adapters/vscode/hooks/post-tool-use.mjs +1 -1
  23. package/templates/adapters/vscode/hooks/prompt-submit.mjs +1 -1
  24. package/templates/adapters/vscode/hooks/session-start.mjs +25 -5
  25. package/templates/adapters/vscode/hooks/stop-check.mjs +2 -2
  26. package/templates/core/AGENTS.md +46 -0
  27. package/templates/core/docs/ADD-governance-claude-code.md +52 -0
  28. package/templates/core/docs/ADD-governance-codex.md +62 -0
  29. package/templates/core/docs/ADD-governance-qoder-cn.md +30 -0
  30. package/templates/core/docs/ADD-governance-trae.md +42 -0
  31. package/templates/core/docs/ADD-governance-vscode-copilot.md +88 -0
  32. package/templates/core/governance/rules.ts +2 -2
  33. package/templates/core/governance/session-start-guard.ts +28 -4
  34. package/templates/core/prisma/add.prisma +4 -0
  35. package/templates/core/scripts/mcp-server/resources/hitl-approval-widget.ts +4 -2
  36. package/templates/core/scripts/mcp-server/resources/index.ts +2 -0
  37. package/templates/core/scripts/mcp-server/resources/widget-probe.ts +53 -0
  38. package/templates/core/scripts/mcp-server/shared/db-types.ts +5 -1
  39. package/templates/core/scripts/mcp-server/shared/dev-operation.ts +97 -0
  40. package/templates/core/scripts/mcp-server/shared/hitl-ui.ts +46 -1
  41. package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +9 -0
  42. package/templates/core/scripts/mcp-server/shared/memory/retrieval/cjk-segmenter.ts +140 -0
  43. package/templates/core/scripts/mcp-server/shared/memory/retrieval/cjk-tokenize.ts +62 -0
  44. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +68 -16
  45. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +3 -3
  46. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +23 -6
  47. package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts-fingerprint.ts +116 -0
  48. package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +9 -25
  49. package/templates/core/scripts/mcp-server/shared/memory/switches.ts +4 -2
  50. package/templates/core/scripts/mcp-server/shared/plan-lifecycle.ts +16 -3
  51. package/templates/core/scripts/mcp-server/shared/server-capabilities.ts +65 -0
  52. package/templates/core/scripts/mcp-server/tools/audit.ts +16 -34
  53. package/templates/core/scripts/mcp-server/tools/gateway/check_add_route_completeness.ts +17 -4
  54. package/templates/core/scripts/mcp-server/tools/gateway/check_add_route_status.ts +34 -6
  55. package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +15 -25
  56. package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +23 -26
  57. package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +56 -38
  58. package/templates/core/scripts/mcp-server/tools/gateway/plan-resolve.ts +270 -0
  59. package/templates/core/scripts/mcp-server/tools/hitl.ts +18 -8
  60. package/templates/core/scripts/mcp-server/tools/index.ts +7 -2
  61. package/templates/core/scripts/mcp-server/tools/memory.ts +235 -1
  62. package/templates/core/scripts/mcp-server/tools/plan-lifecycle.ts +219 -0
  63. package/templates/core/scripts/mcp-server/tools/plan.ts +2 -0
  64. package/templates/core/scripts/mcp-server/tools/widget-probe.ts +53 -0
  65. package/templates/core/scripts/mcp-server.ts +5 -1
  66. package/templates/core/scripts/memory/backfill-search-text.ts +132 -0
  67. package/templates/core/scripts/memory/memory-jobs.ts +124 -0
  68. package/templates/core/scripts/plan-close.ts +85 -0
  69. package/templates/core/skills/add-paradigm/SKILL.md +19 -4
  70. package/templates/core/skills/session-init/SKILL.md +27 -0
  71. package/templates/core/templates/add-route-template-heavyweight.md +2 -0
  72. package/templates/core/templates/add-route-template.md +4 -0
  73. package/templates/core/templates/checklist-template.md +2 -0
  74. package/templates/core/templates/handoff-multi-round-template.md +2 -0
  75. package/templates/core/templates/review-implementation-template.md +18 -0
  76. package/templates/core/templates/review-implementation-template.schema.json +7 -1
  77. package/templates/core/templates/simple-plan-template.md +4 -0
  78. package/templates/core/vocabulary/add-governance-vocabulary.md +2 -0
@@ -0,0 +1,62 @@
1
+ /*
2
+ * CJK 分词契约(单一真源)— Plan add-coder-memory-cjk-bigram-baseline Task 1.1 / Spec §1
3
+ *
4
+ * 为什么需要它:词法基线要从「pg_trgm 相似度 + to_tsvector('simple')」换成 **bigram 分词后的 FTS**,
5
+ * 前提是**写入侧与查询侧共用同一套 tokenization**——否则索引里的 token 与查询 token 对不上,
6
+ * 召回会是"看起来有索引、实际命中不了"。此前 bigram 只存在于查询侧(`query-terms.ts`),
7
+ * 文档侧无对应展开,故本模块把两侧收敛到同一实现、同一上限。
8
+ *
9
+ * 约束(Spec §1 WHEN-THEN):纯函数、无 IO、无依赖;同一输入幂等;超上限按出现顺序确定性截断。
10
+ */
11
+ import { normalizeContent } from "../domain/dedup.js"
12
+
13
+ /** token 上限(与既有 query-terms 的 MAX_TERMS 对齐;确定性截断,不随机抽样) */
14
+ export const MAX_TOKENS = 24
15
+
16
+ /** CJK 连续段(含扩展 A / 假名,与既有 query-terms 的字符集一致) */
17
+ const CJK_SEGMENT = /[㐀-鿿぀-ヿ]+/g
18
+
19
+ /** 拉丁/数字词(≥2 字符;1 字符噪声太大,不作为独立 token) */
20
+ const LATIN_WORD = /[a-z0-9_]{2,}/g
21
+
22
+ /**
23
+ * 统一分词:拉丁词(≥2 字符,大小写归一)+ CJK 段滑动二元组(单字段保留单字)。
24
+ * 返回**去重且按出现顺序**的 token 数组;超 `MAX_TOKENS` 截断。
25
+ */
26
+ export function tokenize(text: string): string[] {
27
+ const norm = normalizeContent(text)
28
+ const terms: string[] = []
29
+ const seen = new Set<string>()
30
+ const push = (t: string) => {
31
+ if (!seen.has(t) && terms.length < MAX_TOKENS) {
32
+ seen.add(t)
33
+ terms.push(t)
34
+ }
35
+ }
36
+
37
+ for (const m of norm.matchAll(LATIN_WORD)) push(m[0])
38
+
39
+ for (const seg of norm.matchAll(CJK_SEGMENT)) {
40
+ const s = seg[0]
41
+ if (s.length === 1) {
42
+ push(s)
43
+ continue
44
+ }
45
+ for (let i = 0; i < s.length - 1; i++) push(s.slice(i, i + 2))
46
+ }
47
+
48
+ return terms
49
+ }
50
+
51
+ /**
52
+ * 文档侧展开:原始文本 → 空格分隔 token 串(写入期落 `searchText`,与查询侧同契约)。
53
+ * 例:`"新增端口"` → `"新增 增端 端口"`。
54
+ */
55
+ export function expandForIndex(text: string): string {
56
+ return tokenize(text).join(" ")
57
+ }
58
+
59
+ /** 查询侧展开:查询串 → token 数组(与 `expandForIndex` 同一实现、同一上限) */
60
+ export function expandForQuery(query: string): string[] {
61
+ return tokenize(query)
62
+ }
@@ -42,6 +42,36 @@ LIMIT $${5 + terms.length}
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * 主通道 SQL(轮 3 / Task 3.1):`searchText` 的 tsvector 表达式索引。
47
+ * `searchText` 由写入期以**同一 tokenization 契约**产出(jieba 主 / bigram 兜底)⇒ 读写同源。
48
+ * 与旧实现的区别:旧 B 通道对 `topic||content` 直接 `to_tsvector('simple')`,中文整段落一个 token(等于没索引)。
49
+ */
50
+ const MAIN_CHANNEL_SQL = `
51
+ SELECT id, ts_rank(to_tsvector('simple', "searchText"), $1::tsquery) AS score
52
+ FROM "AddMemory"
53
+ WHERE "repositoryRef" = $2
54
+ AND "status"::text = ANY($3)
55
+ AND ("validUntil" IS NULL OR "validUntil" > $4)
56
+ AND ($5::text[] IS NULL OR "kind"::text = ANY($5))
57
+ AND to_tsvector('simple', "searchText") @@ $1::tsquery
58
+ ORDER BY score DESC
59
+ LIMIT $6
60
+ `
61
+
62
+ /**
63
+ * 由查询串构造 tsquery:token 用**引号包裹 + OR** 连接(召回优先),并剥离会破坏 tsquery 语法的字符。
64
+ * 返回 null 表示该查询无法构造有效 tsquery(如纯符号)→ 主通道跳过,由补充通道兜底。
65
+ */
66
+ function buildTsquery(query: string): string | null {
67
+ const safe = extractQueryTerms(query)
68
+ .map((t) => t.replace(/[^\p{L}\p{N}_]/gu, ""))
69
+ .filter((t) => t.length > 0)
70
+ if (safe.length === 0) return null
71
+ return safe.map((t) => `'${t}'`).join(" | ")
72
+ }
73
+
74
+ /** 补充通道 A:pg_trgm 相似度(子串/模糊召回;不再是基线) */
45
75
  const CHANNEL_A_SQL = `
46
76
  SELECT id,
47
77
  GREATEST(
@@ -97,38 +127,60 @@ export function createPgFtsAdapter(q: RawQuerier): LexicalSearchAdapter & {
97
127
  searchChannels(query: string, filter: RecallFilter, limit: number): Promise<RankedId[][]>
98
128
  } {
99
129
  return {
100
- id: "pg-trgm",
130
+ id: "pg-bigram-fts",
101
131
  async search(query, filter, limit) {
102
132
  const channels = await this.searchChannels(query, filter, limit)
103
- return channels[0] ?? []
133
+ // 主通道可能因"查询无有效 token"为空(如纯符号)→ 返回首个非空通道,避免整条检索空手而归
134
+ return channels.find((c) => c.length > 0) ?? []
104
135
  },
105
136
  async searchChannels(query, filter, limit) {
106
- const a = await runChannel(q, CHANNEL_A_SQL, query, filter, limit)
107
- let b: RankedId[] = []
108
- try {
109
- b = await runChannel(q, CHANNEL_B_SQL, query, filter, limit)
110
- } catch {
111
- // tsquery 通道失败(如查询含特殊字符)不阻断主通道
137
+ // 主通道:searchText 的 tsvector(读写同源)
138
+ let main: RankedId[] = []
139
+ const tsquery = buildTsquery(query)
140
+ if (tsquery) {
141
+ try {
142
+ const rows = await q.query<{ id: string; score: number | string }>(MAIN_CHANNEL_SQL, [
143
+ tsquery,
144
+ filter.repositoryRef,
145
+ [...filter.statuses],
146
+ filter.now,
147
+ filter.kinds && filter.kinds.length > 0 ? [...filter.kinds] : null,
148
+ limit,
149
+ ])
150
+ main = rows.map((r, i) => ({ memoryId: r.id, rank: i + 1, score: Number(r.score) }))
151
+ } catch {
152
+ // 主通道异常(索引缺失 / tsquery 非法)不阻断补充通道;健康度由 health() 显式上报
153
+ main = []
154
+ }
112
155
  }
156
+ // 补充通道 A:pg_trgm(子串/模糊)
157
+ const a = await runChannel(q, CHANNEL_A_SQL, query, filter, limit)
113
158
  let c: RankedId[] = []
114
159
  const cc = channelC(query, filter, limit)
115
160
  if (cc) {
116
161
  const rows = await q.query<{ id: string; score: number | string }>(cc.sql, cc.params)
117
162
  c = rows.map((r, i) => ({ memoryId: r.id, rank: i + 1, score: Number(r.score) }))
118
163
  }
119
- return [a, b, c]
164
+ return [main, a, c]
120
165
  },
121
166
  async health(): Promise<ComponentHealth> {
122
167
  try {
123
- const ext = await q.query<{ count: number | string }>(
124
- "SELECT COUNT(*)::int AS count FROM pg_extension WHERE extname = 'pg_trgm'", [])
125
- if (Number(ext[0]?.count ?? 0) < 1) {
126
- return { component: "pg-fts", status: "degraded", detail: "pg_trgm 扩展缺失" }
168
+ // 轮 3 / Task 3.1 新判定:**主通道**(searchText 列 + 表达式索引)才决定是否 degraded;
169
+ // 补充通道(pg_trgm)缺失只影响子串/模糊召回,不再整体降级。
170
+ const col = await q.query<{ count: number | string }>(
171
+ "SELECT COUNT(*)::int AS count FROM information_schema.columns WHERE table_name = 'AddMemory' AND column_name = 'searchText'", [])
172
+ if (Number(col[0]?.count ?? 0) < 1) {
173
+ return { component: "pg-fts", status: "degraded", detail: "AddMemory.searchText 列缺失(迁移未应用)" }
174
+ }
175
+ const mainIdx = await q.query<{ count: number | string }>(
176
+ "SELECT COUNT(*)::int AS count FROM pg_indexes WHERE tablename = 'AddMemory' AND indexname = 'AddMemory_searchText_tsv_idx'", [])
177
+ if (Number(mainIdx[0]?.count ?? 0) < 1) {
178
+ return { component: "pg-fts", status: "degraded", detail: "主通道表达式索引缺失(AddMemory_searchText_tsv_idx),需 reindex" }
127
179
  }
128
- const idx = await q.query<{ count: number | string }>(
180
+ const trgm = await q.query<{ count: number | string }>(
129
181
  "SELECT COUNT(*)::int AS count FROM pg_indexes WHERE tablename = 'AddMemory' AND indexname LIKE '%trgm%'", [])
130
- if (Number(idx[0]?.count ?? 0) < 2) {
131
- return { component: "pg-fts", status: "degraded", detail: "trgm GIN 索引缺失,需 reindex" }
182
+ if (Number(trgm[0]?.count ?? 0) < 2) {
183
+ return { component: "pg-fts", status: "ok", detail: "补充通道(pg_trgm)索引缺失:子串/模糊召回受限,主通道正常" }
132
184
  }
133
185
  return { component: "pg-fts", status: "ok" }
134
186
  } catch (e) {
@@ -9,10 +9,10 @@
9
9
  -- (其要求 INTEGER rowid),故采用独立 FTS 表 + 触发器同步。
10
10
  -- 幂等:全部 IF NOT EXISTS,可重复应用。
11
11
 
12
- CREATE VIRTUAL TABLE IF NOT EXISTS add_memory_fts USING fts5(memory_id UNINDEXED, topic, content, tokenize = 'trigram');
12
+ CREATE VIRTUAL TABLE IF NOT EXISTS add_memory_fts USING fts5(memory_id UNINDEXED, searchText, tokenize = 'unicode61');
13
13
 
14
- CREATE TRIGGER IF NOT EXISTS add_memory_fts_ai AFTER INSERT ON "AddMemory" BEGIN INSERT INTO add_memory_fts(memory_id, topic, content) VALUES (new.id, new.topic, new.content); END;
14
+ CREATE TRIGGER IF NOT EXISTS add_memory_fts_ai AFTER INSERT ON "AddMemory" BEGIN INSERT INTO add_memory_fts(memory_id, searchText) VALUES (new.id, new.searchText); END;
15
15
 
16
- CREATE TRIGGER IF NOT EXISTS add_memory_fts_au AFTER UPDATE ON "AddMemory" BEGIN DELETE FROM add_memory_fts WHERE memory_id = old.id; INSERT INTO add_memory_fts(memory_id, topic, content) VALUES (new.id, new.topic, new.content); END;
16
+ CREATE TRIGGER IF NOT EXISTS add_memory_fts_au AFTER UPDATE ON "AddMemory" BEGIN DELETE FROM add_memory_fts WHERE memory_id = old.id; INSERT INTO add_memory_fts(memory_id, searchText) VALUES (new.id, new.searchText); END;
17
17
 
18
18
  CREATE TRIGGER IF NOT EXISTS add_memory_fts_ad AFTER DELETE ON "AddMemory" BEGIN DELETE FROM add_memory_fts WHERE memory_id = old.id; END;
@@ -34,13 +34,24 @@ LIMIT ?
34
34
  return { sql, params: [...termParams, filter.repositoryRef, filter.now.toISOString(), limit] }
35
35
  }
36
36
 
37
- /** 抽取可参与 trigram MATCH 的片段(连续 CJK/字母数字,≥3 字符),逐段加引号 */
38
- export function buildTrigramQuery(raw: string): string | null {
39
- const segments = raw.match(/[㐀-鿿぀-ヿA-Za-z0-9_]+/g) ?? []
40
- const usable = segments.filter((s) => s.length >= 3).map((s) => `"${s}"`)
41
- return usable.length > 0 ? usable.join(" OR ") : null
37
+ /**
38
+ * 构造 FTS5 MATCH 串(轮 3 / Task 3.2)。
39
+ *
40
+ * 变更理由:虚表已从 `topic/content + trigram` 改为 **`searchText` + unicode61**(写入期 token 串),
41
+ * 因此查询侧不再需要"≥3 字符片段"这种 trigram 窗口补偿——直接把查询用**同一 tokenization 契约**
42
+ * 展开成 token,逐 token 加引号后 `OR` 连接即可(1-2 字中文 token 天然可命中)。
43
+ */
44
+ export function buildMatchQuery(raw: string): string | null {
45
+ const tokens = extractQueryTerms(raw)
46
+ .map((t) => t.replace(/"/g, ""))
47
+ .filter((t) => t.length > 0)
48
+ if (tokens.length === 0) return null
49
+ return tokens.map((t) => `"${t}"`).join(" OR ")
42
50
  }
43
51
 
52
+ /** @deprecated 旧名(trigram 时代);等价于 `buildMatchQuery`,保留仅为兼容外部导入 */
53
+ export const buildTrigramQuery = buildMatchQuery
54
+
44
55
  const CHANNEL_A_SQL = `
45
56
  SELECT m.id AS id, bm25(add_memory_fts) AS score
46
57
  FROM add_memory_fts f
@@ -75,7 +86,7 @@ export function createSqliteFtsAdapter(q: RawQuerier): LexicalSearchAdapter & {
75
86
  },
76
87
  async searchChannels(query, filter, limit) {
77
88
  let a: RankedId[] = []
78
- const matchQ = buildTrigramQuery(query)
89
+ const matchQ = buildMatchQuery(query)
79
90
  if (matchQ) {
80
91
  const rows = await q.query<{ id: string; score: number }>(
81
92
  buildSql(CHANNEL_A_SQL, filter), [matchQ, filter.repositoryRef, filter.now.toISOString(), limit],
@@ -97,6 +108,12 @@ export function createSqliteFtsAdapter(q: RawQuerier): LexicalSearchAdapter & {
97
108
  if (rows.length < 1) {
98
109
  return { component: "sqlite-fts", status: "unavailable", detail: "add_memory_fts 虚表缺失,需执行 sqlite-fts5.sql" }
99
110
  }
111
+ // 轮 3 / Task 3.2:虚表列必须是 searchText(unicode61 口径);否则是旧库未升级
112
+ const cols = await q.query<{ name: string }>("PRAGMA table_info(add_memory_fts)", [])
113
+ const hasSearchText = cols.some((c) => c.name === "searchText")
114
+ if (!hasSearchText) {
115
+ return { component: "sqlite-fts", status: "degraded", detail: "FTS 虚表仍是旧列(topic/content),需执行 sqlite-fts5.sql 重建" }
116
+ }
100
117
  return { component: "sqlite-fts", status: "ok" }
101
118
  } catch (e) {
102
119
  return { component: "sqlite-fts", status: "unavailable", detail: e instanceof Error ? e.message : String(e) }
@@ -0,0 +1,116 @@
1
+ // fts-fingerprint.ts — 记忆检索「分词器 + 词典」指纹(Plan 轮 2 Task 2.7 真源)
2
+ //
3
+ // 为什么在 templates 侧:指纹要同时被 **MCP 工具(get_memory_health)** 与 **CLI(memory:reindex --probe)** 消费,
4
+ // 而 templates 是随包分发的真源;`src/lib/memory-fts-fingerprint.ts` 只是面向仓根上下文的再导出。
5
+ //
6
+ // 语义:`searchText` 是写入期产出的 token 串。分词器实现或用户词典一变,历史行的 token 就与查询侧不同源
7
+ // —— 表现为"索引在、命中不了",且**不报错**。指纹给出可判定信号:能否证明历史 token 与当前分词器同源。
8
+ // ① jieba 版本(未装 → "bigram")② 用户词典内容哈希(不存在 → "none")③ tokenization 契约版本
9
+ import { createHash } from "node:crypto"
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
11
+ import { createRequire } from "node:module"
12
+ import { dirname, join } from "node:path"
13
+
14
+ /** tokenization 规则(滑窗/过滤/上限/检索语义)变更时必须递增 */
15
+ export const TOKENIZATION_CONTRACT_VERSION = 1
16
+
17
+ export interface FtsFingerprint {
18
+ value: string
19
+ parts: { jieba: string; userDict: string; contract: number }
20
+ }
21
+
22
+ export function computeFtsFingerprint(input: {
23
+ projectRoot: string
24
+ magicDir: string
25
+ jiebaVersion?: string | null
26
+ userDictPath?: string
27
+ }): FtsFingerprint {
28
+ const userDictPath =
29
+ input.userDictPath ?? join(input.projectRoot, input.magicDir, "data", "jieba", "userdict.txt")
30
+ let userDict = "none"
31
+ if (existsSync(userDictPath)) {
32
+ userDict = createHash("sha256").update(readFileSync(userDictPath)).digest("hex").slice(0, 12)
33
+ }
34
+ const jieba = input.jiebaVersion ?? detectJiebaVersion()
35
+ const value = createHash("sha256")
36
+ .update(`tokenization-v${TOKENIZATION_CONTRACT_VERSION}|${jieba ?? "bigram"}|${userDict}`)
37
+ .digest("hex")
38
+ .slice(0, 12)
39
+ return { value, parts: { jieba: jieba ?? "bigram", userDict, contract: TOKENIZATION_CONTRACT_VERSION } }
40
+ }
41
+
42
+ /** 探测已安装的 jieba 版本;未安装 → null(表示走 bigram 兜底) */
43
+ export function detectJiebaVersion(): string | null {
44
+ try {
45
+ const req = createRequire(import.meta.url)
46
+ const pkg = req("@node-rs/jieba/package.json") as { version?: string }
47
+ return pkg.version ?? "unknown"
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ export interface RecordedFingerprint {
54
+ fingerprint: string
55
+ recordedAt: string
56
+ parts: FtsFingerprint["parts"]
57
+ }
58
+
59
+ export function fingerprintMarkerPath(projectRoot: string, magicDir: string): string {
60
+ return join(projectRoot, magicDir, "memory", "fts-fingerprint.json")
61
+ }
62
+
63
+ export function readRecordedFingerprint(projectRoot: string, magicDir: string): RecordedFingerprint | null {
64
+ const p = fingerprintMarkerPath(projectRoot, magicDir)
65
+ if (!existsSync(p)) return null
66
+ try {
67
+ return JSON.parse(readFileSync(p, "utf-8")) as RecordedFingerprint
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ export function writeRecordedFingerprint(
74
+ projectRoot: string,
75
+ magicDir: string,
76
+ fingerprint: FtsFingerprint,
77
+ now: Date = new Date(),
78
+ ): string {
79
+ const p = fingerprintMarkerPath(projectRoot, magicDir)
80
+ mkdirSync(dirname(p), { recursive: true })
81
+ const payload: RecordedFingerprint = {
82
+ fingerprint: fingerprint.value,
83
+ recordedAt: now.toISOString(),
84
+ parts: fingerprint.parts,
85
+ }
86
+ writeFileSync(p, JSON.stringify(payload, null, 2) + "\n", "utf-8")
87
+ return p
88
+ }
89
+
90
+ export interface FingerprintCheck {
91
+ requiresReindex: boolean
92
+ reason: string
93
+ current: FtsFingerprint
94
+ recorded: RecordedFingerprint | null
95
+ }
96
+
97
+ /**
98
+ * 比对当前指纹与已记录指纹:
99
+ * 未记录(首次部署/标记缺失)→ requiresReindex=true(历史 token 来源未知,不能假定同源);
100
+ * 不一致(jieba 版本/词典/契约变化)→ true + 差异明细;一致 → false。
101
+ */
102
+ export function checkFtsFingerprint(projectRoot: string, magicDir: string): FingerprintCheck {
103
+ const current = computeFtsFingerprint({ projectRoot, magicDir })
104
+ const recorded = readRecordedFingerprint(projectRoot, magicDir)
105
+ if (!recorded) {
106
+ return { requiresReindex: true, reason: "未记录指纹(无法证明历史 searchText 与当前分词器同源)", current, recorded }
107
+ }
108
+ if (recorded.fingerprint !== current.value) {
109
+ const diff = (["jieba", "userDict", "contract"] as const)
110
+ .filter((k) => recorded.parts?.[k] !== current.parts[k])
111
+ .map((k) => `${k}: ${String(recorded.parts?.[k])} → ${String(current.parts[k])}`)
112
+ .join("; ")
113
+ return { requiresReindex: true, reason: `指纹不一致(${diff})`, current, recorded }
114
+ }
115
+ return { requiresReindex: false, reason: "指纹一致", current, recorded }
116
+ }
@@ -1,31 +1,15 @@
1
1
  /*
2
- * 查询词项抽取(双后端 LIKE/bigram 通道共用)
2
+ * 查询词项抽取(双后端 LIKE/bigram 通道共用)—— **薄封装**
3
3
  *
4
- * 背景:trigram 分词器(PG pg_trgm / SQLite FTS5 trigram)是子串语义,
5
- * 对释义查询(如 "新端口怎么申请" vs 内容 "新增端口")召回不足。
6
- * 本模块把查询分解为「拉丁词 + CJK 二元组」词项,供 LIKE 重叠通道计分。
4
+ * [2026-09-21 收敛] 切分实现已迁到单一真源 `cjk-tokenize.ts`(`expandForQuery`)。
5
+ * 本文件只保留既有导出名与签名(调用方不变),**禁止在此再写第二份切分实现**。
6
+ *
7
+ * 为什么收敛:词法基线换成 bigram 分词 FTS 后,写入侧与查询侧必须共用同一 tokenization;
8
+ * 两份实现必然漂移,且漂移后表现为"索引在、命中不了"这类难查的召回缺陷。
7
9
  */
8
- import { normalizeContent } from "../domain/dedup.js"
9
-
10
- const MAX_TERMS = 24
10
+ import { expandForQuery } from "./cjk-tokenize.js"
11
11
 
12
- /** 拉丁/数字词(≥2 字符)+ CJK 连续段的滑动二元组 */
12
+ /** 拉丁/数字词(≥2 字符)+ CJK 连续段的滑动二元组(委托 `cjk-tokenize.ts`) */
13
13
  export function extractQueryTerms(query: string): string[] {
14
- const norm = normalizeContent(query)
15
- const terms: string[] = []
16
- const seen = new Set<string>()
17
- const push = (t: string) => {
18
- if (!seen.has(t) && terms.length < MAX_TERMS) { seen.add(t); terms.push(t) }
19
- }
20
-
21
- // 拉丁词
22
- for (const m of norm.matchAll(/[a-z0-9_]{2,}/g)) push(m[0])
23
-
24
- // CJK 连续段 → 滑动二元组(单字段保留单字)
25
- for (const seg of norm.matchAll(/[㐀-鿿぀-ヿ]+/g)) {
26
- const s = seg[0]
27
- if (s.length === 1) { push(s); continue }
28
- for (let i = 0; i < s.length - 1; i++) push(s.slice(i, i + 2))
29
- }
30
- return terms
14
+ return expandForQuery(query)
31
15
  }
@@ -5,7 +5,9 @@
5
5
  * 因此全部函数必须是同步、纯环境变量读取,禁止任何 IO/DB 访问。
6
6
  *
7
7
  * 开关:
8
- * - ADD_MEMORY_RECALL_MODE = off | shadow | inject(默认 shadow:召回可执行并落审计,但 Hook 不注入上下文)
8
+ * - ADD_MEMORY_RECALL_MODE = off | shadow | inject(**默认 inject**:召回并注入 L1;
9
+ * 2026-09-21 人类决策 —— 默认 shadow 会让记忆功能形同未启用。非法值回落 shadow(安全侧,且必然打印档位)。
10
+ * 退回不注入:设 ADD_MEMORY_RECALL_MODE=shadow)
9
11
  * - ADD_MEMORY_MAX_TOKENS:L1 注入 token 预算(默认 600)
10
12
  * - ADD_MEMORY_EVIDENCE = off | on(默认 on:PostToolUse 白名单采证入队)
11
13
  */
@@ -13,7 +15,7 @@
13
15
  export type RecallMode = "off" | "shadow" | "inject"
14
16
 
15
17
  export function recallMode(env: NodeJS.ProcessEnv = process.env): RecallMode {
16
- const v = (env.ADD_MEMORY_RECALL_MODE ?? "shadow").toLowerCase()
18
+ const v = (env.ADD_MEMORY_RECALL_MODE ?? "inject").toLowerCase()
17
19
  return v === "off" || v === "inject" ? v : "shadow"
18
20
  }
19
21
 
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeContextKey } from "./runtime-context.js"
2
+ import * as z from "zod/v4"
2
3
 
3
4
  export const PLAN_LIFECYCLE_STATUSES = [
4
5
  "DRAFT",
@@ -6,10 +7,13 @@ export const PLAN_LIFECYCLE_STATUSES = [
6
7
  "BLOCKED",
7
8
  "REJECTED",
8
9
  "CLOSED",
10
+ "REOPENED",
9
11
  "ABANDONED",
10
12
  ] as const
11
13
 
12
14
  export type PlanLifecycleStatus = (typeof PLAN_LIFECYCLE_STATUSES)[number]
15
+ /** lifecycle 的**唯一 zod 真源**:工具 inputSchema / db-types / 脚本校验一律 import 它,禁止再抄字面量(2026-09-21 决策) */
16
+ export const PlanLifecycleStatusSchema = z.enum(PLAN_LIFECYCLE_STATUSES)
13
17
  export type PlanApprovalStatus = "DRAFT" | "SUBMITTED" | "TONGYI" | "BOHUI"
14
18
 
15
19
  export interface ScopedPlanStatusRow {
@@ -75,12 +79,21 @@ export interface PlanStatusUnavailable {
75
79
  export type PlanStatusResolution = PlanStatusSnapshot | NoActivePlanSnapshot | PlanStatusUnavailable
76
80
 
77
81
  const ALLOWED_TRANSITIONS: Readonly<Record<PlanLifecycleStatus, readonly PlanLifecycleStatus[]>> = {
82
+ // 两个状态机不要混:TONGYI/BOHUI 属**审批**状态机(HitlRecord.status);
83
+ // 下面是 **Plan 生命周期**状态机(PlanLifecycleStatus),ABANDONED = 放弃该 Plan(非审批结论)。
78
84
  DRAFT: ["ACTIVE", "REJECTED", "ABANDONED"],
79
85
  ACTIVE: ["BLOCKED", "CLOSED", "ABANDONED"],
80
86
  BLOCKED: ["ACTIVE", "CLOSED", "ABANDONED"],
81
- REJECTED: ["DRAFT", "ACTIVE", "ABANDONED"],
82
- CLOSED: [],
83
- ABANDONED: [],
87
+ // 驳回(BOHUI → REJECTED)= 不继续;后续可 归档(CLOSED) / 重启(REOPENED) / 回起草或施工 / 放弃(ABANDONED)
88
+ REJECTED: ["DRAFT", "ACTIVE", "CLOSED", "REOPENED", "ABANDONED"],
89
+ // 可逆 + PUL 重开(2026-09-21 人类决策):
90
+ // - CLOSED → REOPENED:策略更新(PUL)或误关场景下重开,reopenCycle +1(与施工轮次 round 分离)
91
+ // - CLOSED → ACTIVE:直达重开(无需标记代数时的简路径)
92
+ CLOSED: ["REOPENED", "ACTIVE"],
93
+ // REOPENED 是**瞬态**:重开后完成新一轮 Step 0 即回 ACTIVE;也允许再次 CLOSED(重开后又关)
94
+ REOPENED: ["ACTIVE", "CLOSED"],
95
+ // 接线(2026-09-21 人类决策):ABANDONED 保留为生命周期状态,且与 CLOSED 同口径**可逆**(放弃后可复活)
96
+ ABANDONED: ["ACTIVE"],
84
97
  }
85
98
 
86
99
  export function isActiveLifecycle(lifecycle: PlanLifecycleStatus): boolean {
@@ -0,0 +1,65 @@
1
+ // MCP Server 能力声明与 instructions(Plan add-coder-multi-host-adapter-alignment Task 2.1 / 2.2)
2
+ //
3
+ // 为什么单独成模块:`mcp-server.ts` 是"进程入口"(import 即启动 main()),
4
+ // 能力声明若内联在那里就无法被单测断言(import 会拉起 stdio server)。
5
+ // 本模块零副作用,供入口与 `tests/mcp-apps-capability.test.ts` 共用同一份真源。
6
+
7
+ // MCP Apps 官方扩展标识(extensions/apps;客户端矩阵见 modelcontextprotocol.io/extensions/client-matrix)。
8
+ //
9
+ // 声明它的收益:宿主(Codex desktop / VS Code Copilot / Cursor 等)可在扩展协商通过后绑定 widget;
10
+ // 不声明时只能靠工具侧 legacy 位(`_meta.ui.resourceUri` + `openai/outputTemplate`)兼容旧宿主。
11
+ // 两条路径并存——删掉 legacy 位会让老宿主失效,删掉扩展声明则在新宿主收紧协商后全端同时失效。
12
+ export const MCP_APPS_EXTENSION_ID = "io.modelcontextprotocol/ui"
13
+
14
+ // instructions 长度上限(字节):Claude Code 对 server instructions 与工具描述各截断 2KB
15
+ export const SERVER_INSTRUCTIONS_MAX_BYTES = 2048
16
+
17
+ // 前缀自包含口径(字符):Codex 官方要求 instructions 前 512 字符自包含
18
+ export const SERVER_INSTRUCTIONS_PREFIX_CHARS = 512
19
+
20
+ const INSTRUCTIONS_PART_1 = [
21
+ "add-coder 治理工具(ADD 范式)。三个必做 WHEN:",
22
+ "① 新会话或跨轮恢复 → 先 get_project_context(scope:'add-state');",
23
+ "② 改完任何文件 → record_dev_operation(ADD-7 审计不可省);",
24
+ "③ Plan 启动或评审 → create_hitl,由人类 update_hitl 裁决,未 TONGYI 不得进入实现。",
25
+ ].join("\n")
26
+
27
+ const INSTRUCTIONS_PART_2 = [
28
+ "工具族分区:",
29
+ "状态与上下文 get_project_context / find_related_docs / get_db_schema;",
30
+ "Plan-Review 生命周期 plan_track / plan_status / plan_sync / plan_update / review_track / review_status / review_sync;",
31
+ "HITL 审批 create_hitl / update_hitl / status_hitl / render_hitl_approval;",
32
+ "审计 query_audit_logs / record_dev_operation;",
33
+ "质量门禁 check_dps / check_rahs / check_add_route_status / check_add_route_completeness / check_add_compliance / check_spec_sync;",
34
+ "记忆 propose_memory / recall_memory / refresh_memory_snapshots / get_memory_health。",
35
+ ].join("\n")
36
+
37
+ const INSTRUCTIONS_PART_3 = [
38
+ "面板不可用时:用 render_hitl_approval 返回的 fallback(markdownPath / htmlPath)人工确认后调 update_hitl 落库。",
39
+ "收口前 check_dps 与 check_rahs 均需达标(≥80 / ≥90)再声明收敛。",
40
+ ].join("\n")
41
+
42
+ // server instructions 真源(≤ SERVER_INSTRUCTIONS_MAX_BYTES 字节;首段自包含)。
43
+ // 约束由 tests/mcp-apps-capability.test.ts 守护:超长即测试失败,禁止静默截断。
44
+ export const SERVER_INSTRUCTIONS = [INSTRUCTIONS_PART_1, INSTRUCTIONS_PART_2, INSTRUCTIONS_PART_3].join("\n")
45
+
46
+ export interface ServerCapabilitiesOptions {
47
+ capabilities: {
48
+ tools: Record<string, never>
49
+ resources: { subscribe: boolean }
50
+ extensions: Record<string, Record<string, never>>
51
+ }
52
+ instructions: string
53
+ }
54
+
55
+ // 入口 `new McpServer(...)` 的第二参数(供入口与用例共用同一真源)
56
+ export function buildServerOptions(): ServerCapabilitiesOptions {
57
+ return {
58
+ capabilities: {
59
+ tools: {},
60
+ resources: { subscribe: true },
61
+ extensions: { [MCP_APPS_EXTENSION_ID]: {} },
62
+ },
63
+ instructions: SERVER_INSTRUCTIONS,
64
+ }
65
+ }
@@ -6,6 +6,7 @@ import { prisma } from "../shared/prisma.js"
6
6
  import { PROJECT_ID, PROJECT_ROOT, getRuntimeContext } from "../shared/env.js"
7
7
  import type { AddUserRow, DevOperationRow } from "../shared/db-types.js"
8
8
  import { AddUserRowSchema, DevOperationRowSchema, validatedDelegate } from "../shared/db-types.js"
9
+ import { writeDevOperation, type DevOperationWriterDatabase } from "../shared/dev-operation.js"
9
10
 
10
11
  export function registerAuditTools(server: ToolRegistrar) {
11
12
  const runtimeContext = getRuntimeContext()
@@ -82,41 +83,22 @@ export function registerAuditTools(server: ToolRegistrar) {
82
83
  try { if (beforeState) parsedBefore = JSON.parse(s(beforeState)); if (afterState) parsedAfter = JSON.parse(s(afterState)) } catch { return errorResponse("beforeState/afterState 必须是有效的 JSON 字符串。") }
83
84
  const isStructuredState = (value: unknown): value is Record<string, unknown> | unknown[] => typeof value === "object" && value !== null
84
85
  if (!isStructuredState(parsedBefore) || !isStructuredState(parsedAfter)) return errorResponse("beforeState/afterState 必须是非 null 的 JSON 对象或数组。")
85
- let systemUser = await userDb.findUnique({ where: { username: "ai-assistant" }, select: { id: true } })
86
- if (!systemUser) systemUser = await userDb.create({ data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" } })
87
- const operationPayload = {
88
- projectKey: runtimeContext.projectKey,
89
- producerAdapterKey: runtimeContext.adapterKey,
90
- action: s(action),
91
- targetType: s(targetType),
92
- targetId: tId || "unknown",
93
- planKeyword: s(planKeyword) || "unknown",
94
- beforeState: parsedBefore,
95
- afterState: parsedAfter,
96
- reason: s(reason) || null,
97
- }
98
- const resolvedOperationKey = s(operationKey).trim() || createHash("sha256")
99
- .update(JSON.stringify(operationPayload))
100
- .digest("hex")
101
- const scopedData: Partial<DevOperationRow> = {
102
- ...operationPayload,
103
- userId: systemUser.id,
104
- contextId: runtimeContext.contextId,
105
- toolName: "record_dev_operation",
106
- operationKey: resolvedOperationKey,
107
- }
108
- const log = await devDb.upsert({
109
- where: {
110
- projectKey_producerAdapterKey_toolName_operationKey: {
111
- projectKey: runtimeContext.projectKey,
112
- producerAdapterKey: runtimeContext.adapterKey,
113
- toolName: "record_dev_operation",
114
- operationKey: resolvedOperationKey,
115
- },
86
+ // 单一实现(2026-09-21 决策):写入抽到 shared/dev-operation.ts,与 plan_update / 脚本共用;
87
+ // 幂等键语义逐字保持不变(hash 载荷不含 toolName,换调用方不产生新审计行)
88
+ const log = await writeDevOperation(
89
+ { devOperation: devDb, addUser: userDb } as unknown as DevOperationWriterDatabase,
90
+ {
91
+ context: runtimeContext,
92
+ action: s(action),
93
+ targetType: s(targetType),
94
+ targetId: tId,
95
+ planKeyword: s(planKeyword),
96
+ beforeState: parsedBefore,
97
+ afterState: parsedAfter,
98
+ reason: s(reason) || null,
99
+ operationKey: s(operationKey),
116
100
  },
117
- create: scopedData,
118
- update: {},
119
- })
101
+ )
120
102
  const lines = [`✅ 开发操作已记录`, ` 落库项目: ${PROJECT_ID} (${PROJECT_ROOT})`, ` ID: ${log.id}`, ` action: ${s(action)}`, ` targetType: ${s(targetType)}`, ` targetId: ${tId || "unknown"}`, ` planKeyword: ${s(planKeyword) || "unknown"}`, ` beforeState: ${JSON.stringify(log.beforeState)}`, ` afterState: ${JSON.stringify(log.afterState)}`, ` createdAt: ${log.createdAt.toISOString()}`]
121
103
  if (pathWarnings.length > 0) { lines.push(""); lines.push(...pathWarnings) }
122
104
  lines.push("", `📋 落库回查(必须执行):`, tId ? ` query_audit_logs({ targetId: "${tId}" }) — 确认本条记录已写入` : "")
@@ -17,6 +17,7 @@ import {
17
17
  PROJECT_ROOT,
18
18
  MAGIC_DIR,
19
19
  } from "../../shared/fs.js";
20
+ import { resolvePlanArtifact } from "./plan-resolve.js";
20
21
 
21
22
  export function registerCheckAddRouteCompleteness(server: ToolRegistrar) {
22
23
  server.registerTool(
@@ -36,15 +37,26 @@ export function registerCheckAddRouteCompleteness(server: ToolRegistrar) {
36
37
  if (!existsSync(plansDir))
37
38
  return errorResponse(`plans 目录不存在: ${plansDir}`);
38
39
  const allFiles = await readdirRecursive(plansDir);
39
- const arFile = allFiles.find(
40
- (f) =>
41
- f.toLowerCase().includes(pp.toLowerCase()) &&
42
- f.includes("add-route"),
40
+ // 版本配对(2026-09-18 修复):与 Plan 同目录同版本优先,不再"去版本取首个匹配"(永远命中 v1)
41
+ const { plan: resolvedPlan, artifact: resolvedAr } = resolvePlanArtifact(
42
+ allFiles,
43
+ pp,
44
+ "add-route",
43
45
  );
46
+ const arFile = resolvedAr?.file;
44
47
  if (!arFile)
45
48
  return errorResponse(`未找到匹配的 add-route 文件(关键词: ${pp})`);
46
49
  const content = await readFileSafe(join(plansDir, arFile));
47
50
  if (!content) return errorResponse("add-route 文件无法读取");
51
+ const pairing: string[] = [];
52
+ if (resolvedPlan)
53
+ pairing.push(
54
+ `Plan: ${resolvedPlan.file}${resolvedPlan.version > 0 ? ` (v${resolvedPlan.version})` : ""}`,
55
+ );
56
+ pairing.push(
57
+ `add-route: ${arFile}${resolvedAr && resolvedAr.version > 0 ? ` (v${resolvedAr.version})` : ""} 〔配对依据: ${resolvedAr?.via}〕`,
58
+ );
59
+ if (resolvedAr?.warning) pairing.push(`⚠️ ${resolvedAr.warning}`);
48
60
  const steps: Record<string, { checked: number; unchecked: number }> =
49
61
  {};
50
62
  let cur = "";
@@ -71,6 +83,7 @@ export function registerCheckAddRouteCompleteness(server: ToolRegistrar) {
71
83
  const parts = [
72
84
  `=== add-route Step 完成度扫描 ===`,
73
85
  `文件: ${MAGIC_DIR}/plans/${arFile}`,
86
+ ...pairing,
74
87
  `整体: ${tu}/${tc} (${tc > 0 ? Math.round((tu / tc) * 100) : 0}%)`,
75
88
  "",
76
89
  ];