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,143 @@
1
+ /*
2
+ * pgvector 向量检索适配器(Plan §3.4 轮 3 Task 3.2 / Spec §6 §VectorCapability)
3
+ *
4
+ * 设计要点:
5
+ * 1. **Prisma schema 不表达向量列**(架构文档定案):扩展/表/索引由本适配器的原生 DDL 管理,
6
+ * `ensureSchema()` 幂等可重放;对应 migration 只负责首次交付(见 3.4);
7
+ * 2. **能力检测优先**:`capability()` 先看扩展、再看表;任一缺失都返回 unavailable/degraded,
8
+ * 调用方据此走 fts-only,绝不因向量缺失阻塞召回;
9
+ * 3. **维度真源**:表按 provider 维度建列;写入前校验向量长度,禁止脏向量进库。
10
+ */
11
+ import type { ComponentHealth, RankedId, RecallFilter, RawQuerier } from "../types.js"
12
+ import { MemoryError } from "../../domain/errors.js"
13
+ import type { VectorSearchAdapter } from "../../embedding/index.js"
14
+
15
+ export const PG_VECTOR_TABLE = "add_memory_vector"
16
+ export const PG_VECTOR_EXTENSION = "vector"
17
+
18
+ /** 向量字面量(pgvector 接受 '[1,2,3]' 形式) */
19
+ export function pgVectorLiteral(vector: readonly number[]): string {
20
+ return `[${vector.map((v) => (Number.isFinite(v) ? v : 0)).join(",")}]`
21
+ }
22
+
23
+ export function pgVectorSchemaDdl(dimension: number, table = PG_VECTOR_TABLE): string[] {
24
+ if (!Number.isFinite(dimension) || dimension <= 0) {
25
+ throw new MemoryError("ERR_DIMENSION_MISMATCH", `非法向量维度:${dimension}`)
26
+ }
27
+ return [
28
+ `CREATE EXTENSION IF NOT EXISTS vector;`,
29
+ `CREATE TABLE IF NOT EXISTS "${table}" (
30
+ "memory_id" text PRIMARY KEY,
31
+ "model" text NOT NULL,
32
+ "dim" integer NOT NULL,
33
+ "embedding" vector(${dimension}) NOT NULL,
34
+ "updated_at" timestamptz NOT NULL DEFAULT now()
35
+ );`,
36
+ `CREATE INDEX IF NOT EXISTS "${table}_embedding_hnsw" ON "${table}" USING hnsw ("embedding" vector_cosine_ops);`,
37
+ ]
38
+ }
39
+
40
+ export interface PgVectorAdapterOptions {
41
+ querier: RawQuerier
42
+ /** 维度真源(来自 provider 元数据) */
43
+ dimension: number
44
+ /** 模型标识:检索时按模型过滤,避免混用不同模型向量 */
45
+ model: string
46
+ table?: string
47
+ }
48
+
49
+ export interface PgVectorAdapter extends VectorSearchAdapter {
50
+ capability(): Promise<ComponentHealth>
51
+ ensureSchema(): Promise<{ statements: number }>
52
+ }
53
+
54
+ export function createPgVectorAdapter(opts: PgVectorAdapterOptions): PgVectorAdapter {
55
+ const table = opts.table ?? PG_VECTOR_TABLE
56
+
57
+ function assertDim(vector: readonly number[]): void {
58
+ if (vector.length !== opts.dimension) {
59
+ throw new MemoryError(
60
+ "ERR_DIMENSION_MISMATCH",
61
+ `向量维度不符:实际 ${vector.length} vs 期望 ${opts.dimension}`,
62
+ )
63
+ }
64
+ }
65
+
66
+ async function capability(): Promise<ComponentHealth> {
67
+ try {
68
+ const ext = await opts.querier.query<{ name: string }>(
69
+ `SELECT extname AS name FROM pg_extension WHERE extname = $1`,
70
+ [PG_VECTOR_EXTENSION],
71
+ )
72
+ if (ext.length === 0) {
73
+ return {
74
+ component: "vector-search",
75
+ status: "unavailable",
76
+ detail: `pgvector 扩展未安装(table=${table})→ 降级 fts-only`,
77
+ }
78
+ }
79
+ const tbl = await opts.querier.query<{ name: string | null }>(
80
+ `SELECT to_regclass($1)::text AS name`,
81
+ [`public.${table}`],
82
+ )
83
+ if (!tbl[0]?.name) {
84
+ return {
85
+ component: "vector-search",
86
+ status: "degraded",
87
+ detail: `pgvector 已装但表 ${table} 缺失 → 需 ensureSchema()`,
88
+ }
89
+ }
90
+ return { component: "vector-search", status: "ok", detail: `pgvector table=${table} dim=${opts.dimension}` }
91
+ } catch (error) {
92
+ return {
93
+ component: "vector-search",
94
+ status: "unavailable",
95
+ detail: `pgvector 探测失败:${error instanceof Error ? error.message : String(error)}`,
96
+ }
97
+ }
98
+ }
99
+
100
+ return {
101
+ async ensureSchema(): Promise<{ statements: number }> {
102
+ const ddl = pgVectorSchemaDdl(opts.dimension, table)
103
+ for (const sql of ddl) await opts.querier.query(sql, [])
104
+ return { statements: ddl.length }
105
+ },
106
+
107
+ async upsert(memoryId: string, vector: number[], model: string): Promise<void> {
108
+ assertDim(vector)
109
+ await opts.querier.query(
110
+ `INSERT INTO "${table}" ("memory_id", "model", "dim", "embedding")
111
+ VALUES ($1, $2, $3, $4::vector)
112
+ ON CONFLICT ("memory_id") DO UPDATE
113
+ SET "model" = EXCLUDED."model",
114
+ "dim" = EXCLUDED."dim",
115
+ "embedding" = EXCLUDED."embedding",
116
+ "updated_at" = now()`,
117
+ [memoryId, model, vector.length, pgVectorLiteral(vector)],
118
+ )
119
+ },
120
+
121
+ async search(vector: number[], filter: RecallFilter, limit: number): Promise<RankedId[]> {
122
+ assertDim(vector)
123
+ const rows = await opts.querier.query<{ memory_id: string; score: number | string }>(
124
+ `SELECT v."memory_id" AS memory_id, 1 - (v."embedding" <=> $1::vector) AS score
125
+ FROM "${table}" v
126
+ JOIN "AddMemory" m ON m."id" = v."memory_id"
127
+ WHERE m."repositoryRef" = $2
128
+ AND m."status"::text = ANY($3)
129
+ AND v."model" = $4
130
+ ORDER BY v."embedding" <=> $1::vector
131
+ LIMIT $5`,
132
+ [pgVectorLiteral(vector), filter.repositoryRef, [...filter.statuses], opts.model, limit],
133
+ )
134
+ return rows.map((r, i) => ({ memoryId: r.memory_id, rank: i + 1, score: Number(r.score) }))
135
+ },
136
+
137
+ async health(): Promise<ComponentHealth> {
138
+ return capability()
139
+ },
140
+
141
+ capability,
142
+ }
143
+ }
@@ -0,0 +1,118 @@
1
+ /*
2
+ * sqlite-vec 向量检索适配器(Plan §3.4 轮 3 Task 3.2 / Spec §6 §VectorCapability)
3
+ *
4
+ * ⚠️ 平台说明(诚实登记):sqlite-vec 是原生扩展,**本仓库当前环境未安装**,因此本文件的
5
+ * SQL 路径只在扩展存在时才被执行(capability 探测先行)。DDL 与函数名集中在文件顶部常量,
6
+ * 便于在具备扩展的环境上校正;未安装时 `capability()` 返回 unavailable,召回走 fts-only。
7
+ *
8
+ * 与 pgvector 适配器同契约:能力检测 → 幂等 ensureSchema → 维度校验 → upsert/search。
9
+ */
10
+ import type { ComponentHealth, RankedId, RecallFilter, RawQuerier } from "../types.js"
11
+ import { MemoryError } from "../../domain/errors.js"
12
+ import type { VectorSearchAdapter } from "../../embedding/index.js"
13
+
14
+ export const SQLITE_VEC_TABLE = "add_memory_vector"
15
+ /** 能力探测:vec0 模块是否注册(只读) */
16
+ export const SQLITE_VEC_CAPABILITY_SQL = `SELECT 1 AS ok FROM pragma_module_list WHERE name = 'vec0'`
17
+ /** 距离函数(余弦距离,越小越近) */
18
+ export const SQLITE_VEC_DISTANCE_FN = "vec_distance_cosine"
19
+
20
+ export function sqliteVecSchemaDdl(dimension: number, table = SQLITE_VEC_TABLE): string[] {
21
+ if (!Number.isFinite(dimension) || dimension <= 0) {
22
+ throw new MemoryError("ERR_DIMENSION_MISMATCH", `非法向量维度:${dimension}`)
23
+ }
24
+ return [
25
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING vec0(memory_id text primary key, embedding float[${dimension}]);`,
26
+ `CREATE TABLE IF NOT EXISTS ${table}_meta (memory_id text PRIMARY KEY, model text NOT NULL, dim integer NOT NULL, updated_at text NOT NULL DEFAULT (datetime('now')));`,
27
+ ]
28
+ }
29
+
30
+ export interface SqliteVecAdapterOptions {
31
+ querier: RawQuerier
32
+ dimension: number
33
+ model: string
34
+ table?: string
35
+ }
36
+
37
+ export interface SqliteVecAdapter extends VectorSearchAdapter {
38
+ capability(): Promise<ComponentHealth>
39
+ ensureSchema(): Promise<{ statements: number }>
40
+ }
41
+
42
+ export function createSqliteVecAdapter(opts: SqliteVecAdapterOptions): SqliteVecAdapter {
43
+ const table = opts.table ?? SQLITE_VEC_TABLE
44
+
45
+ function assertDim(vector: readonly number[]): void {
46
+ if (vector.length !== opts.dimension) {
47
+ throw new MemoryError(
48
+ "ERR_DIMENSION_MISMATCH",
49
+ `向量维度不符:实际 ${vector.length} vs 期望 ${opts.dimension}`,
50
+ )
51
+ }
52
+ }
53
+
54
+ async function capability(): Promise<ComponentHealth> {
55
+ try {
56
+ const rows = await opts.querier.query<{ ok: number }>(SQLITE_VEC_CAPABILITY_SQL, [])
57
+ if (rows.length === 0) {
58
+ return {
59
+ component: "vector-search",
60
+ status: "unavailable",
61
+ detail: "sqlite-vec(vec0) 未安装 → 降级 fts-only",
62
+ }
63
+ }
64
+ return { component: "vector-search", status: "ok", detail: `sqlite-vec table=${table} dim=${opts.dimension}` }
65
+ } catch (error) {
66
+ return {
67
+ component: "vector-search",
68
+ status: "unavailable",
69
+ detail: `sqlite-vec 探测失败:${error instanceof Error ? error.message : String(error)}`,
70
+ }
71
+ }
72
+ }
73
+
74
+ return {
75
+ async ensureSchema(): Promise<{ statements: number }> {
76
+ const ddl = sqliteVecSchemaDdl(opts.dimension, table)
77
+ for (const sql of ddl) await opts.querier.query(sql, [])
78
+ return { statements: ddl.length }
79
+ },
80
+
81
+ async upsert(memoryId: string, vector: number[], model: string): Promise<void> {
82
+ assertDim(vector)
83
+ // vec_f32 接受 JSON 文本形式;绑定为字符串参数
84
+ await opts.querier.query(
85
+ `INSERT INTO ${table}(memory_id, embedding) VALUES (?, vec_f32(?))
86
+ ON CONFLICT(memory_id) DO UPDATE SET embedding = vec_f32(?)`,
87
+ [memoryId, JSON.stringify(vector), JSON.stringify(vector)],
88
+ )
89
+ await opts.querier.query(
90
+ `INSERT INTO ${table}_meta(memory_id, model, dim) VALUES (?, ?, ?)
91
+ ON CONFLICT(memory_id) DO UPDATE SET model = excluded.model, dim = excluded.dim`,
92
+ [memoryId, model, vector.length],
93
+ )
94
+ },
95
+
96
+ async search(vector: number[], filter: RecallFilter, limit: number): Promise<RankedId[]> {
97
+ assertDim(vector)
98
+ const rows = await opts.querier.query<{ memory_id: string; distance: number | string }>(
99
+ `SELECT v.memory_id AS memory_id, ${SQLITE_VEC_DISTANCE_FN}(v.embedding, vec_f32(?)) AS distance
100
+ FROM ${table} v
101
+ JOIN ${table}_meta meta ON meta.memory_id = v.memory_id
102
+ JOIN "AddMemory" m ON m.id = v.memory_id
103
+ WHERE m.repositoryRef = ?
104
+ AND meta.model = ?
105
+ ORDER BY distance ASC
106
+ LIMIT ?`,
107
+ [JSON.stringify(vector), filter.repositoryRef, opts.model, limit],
108
+ )
109
+ return rows.map((r, i) => ({ memoryId: r.memory_id, rank: i + 1, score: 1 - Number(r.distance) }))
110
+ },
111
+
112
+ async health(): Promise<ComponentHealth> {
113
+ return capability()
114
+ },
115
+
116
+ capability,
117
+ }
118
+ }
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Memory 子系统发布开关(Spec §10,Plan §9.3)
3
+ *
4
+ * 约束:governance Hook(同步 spawn 子进程,≤200ms 预算)与 jobs(异步)共用本模块,
5
+ * 因此全部函数必须是同步、纯环境变量读取,禁止任何 IO/DB 访问。
6
+ *
7
+ * 开关:
8
+ * - ADD_MEMORY_RECALL_MODE = off | shadow | inject(默认 shadow:召回可执行并落审计,但 Hook 不注入上下文)
9
+ * - ADD_MEMORY_MAX_TOKENS:L1 注入 token 预算(默认 600)
10
+ * - ADD_MEMORY_EVIDENCE = off | on(默认 on:PostToolUse 白名单采证入队)
11
+ */
12
+
13
+ export type RecallMode = "off" | "shadow" | "inject"
14
+
15
+ export function recallMode(env: NodeJS.ProcessEnv = process.env): RecallMode {
16
+ const v = (env.ADD_MEMORY_RECALL_MODE ?? "shadow").toLowerCase()
17
+ return v === "off" || v === "inject" ? v : "shadow"
18
+ }
19
+
20
+ export function memoryMaxTokens(env: NodeJS.ProcessEnv = process.env): number {
21
+ const n = Number(env.ADD_MEMORY_MAX_TOKENS ?? 600)
22
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 600
23
+ }
24
+
25
+ export function evidenceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
26
+ return (env.ADD_MEMORY_EVIDENCE ?? "on").toLowerCase() !== "off"
27
+ }
28
+
29
+ /** 记忆工作目录(快照 + 事件队列所在):${magicDir}/memory/ */
30
+ export const MEMORY_DIR_NAME = "memory"
31
+ /** L1/L2 快照文件名 */
32
+ export const L1_SNAPSHOT_FILE = "l1-context.md"
33
+ export const L2_SNAPSHOT_FILE = "l2-context.md"
34
+ /** PostToolUse 采证事件队列(jsonl,append-only,由 consolidation job 消费) */
35
+ export const EVIDENCE_QUEUE_FILE = "evidence-queue.jsonl"
36
+ /** evidence 队列消费进度标记(已处理字节偏移) */
37
+ export const EVIDENCE_OFFSET_FILE = "evidence-queue.offset"
38
+ /** L1 快照新鲜度上限(毫秒,默认 7 天):过期不注入 */
39
+ export const L1_SNAPSHOT_TTL_MS = 7 * 24 * 60 * 60 * 1000
@@ -0,0 +1,22 @@
1
+ /*
2
+ * 审查文档命名识别(纯函数;2026-09-14 从 tools/review.ts 抽出)
3
+ *
4
+ * 抽出原因:① 纯函数不该和 prisma/工具注册绑在一起(拖进来就要求 DB 依赖,测试得打 mock);
5
+ * ② 仓库里并存两套命名,识别规则需要被用例直接钉住:
6
+ * - `{planPrefix}-review-v1.md`(历史)
7
+ * - `{planPrefix}-plan-v1-review.md`(create_hitl 的占位路径落点/现行)
8
+ * 旧匹配要求包含 `-review-`(review 后必须紧跟连字符),会把后一整类漏掉。
9
+ */
10
+ import { basename } from "path"
11
+
12
+ /** 从文件列表里挑出审查文档(.md 且文件名含 review,大小写不敏感) */
13
+ export function pickReviewFiles(files: readonly string[]): string[] {
14
+ return files.filter((f) => f.endsWith(".md") && basename(f).toLowerCase().includes("review"))
15
+ }
16
+
17
+ /** 从审查文件名推导 planName 前缀(两种命名都要剥干净) */
18
+ export function derivePlanNameFromReviewFile(file: string): string {
19
+ return basename(file, ".md")
20
+ .replace(/-review-(implementation|runtime).*$/, "") // 变体后缀
21
+ .replace(/-review.*$/, "") // 普通 review 后缀(含 -review-v1 / -review)
22
+ }
@@ -0,0 +1,235 @@
1
+ /*
2
+ * 产物-进程新鲜度(Plan hitl-widget-runtime-gap §Freshness)
3
+ *
4
+ * 背景:`npm run sync` 会重写 `{magicDir}/scripts/mcp-server.ts` 等产物,但不会重启正在运行的
5
+ * server 进程 → 出现「产物是新的、跑的是旧代码」的静默陈旧(实测:进程 Sep 12 11:49 vs 产物 09-13 09:47)。
6
+ *
7
+ * 覆盖面:**6 个 adapter 各自都有 server 副本**,因此检测范围是全部 magic 目录,不限于 .codex。
8
+ * 判定不确定时一律返回 "unknown"(宁可漏报,不误报"需重启")。
9
+ */
10
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
11
+ import { join } from "node:path"
12
+ import { execSync } from "node:child_process"
13
+
14
+ export const RESTART_MARKER_FILE = ".mcp-restart-required"
15
+ export const DEFAULT_MAGIC_DIRS = [".codex", ".add", ".qoder", ".claude", ".vscode", ".trae"] as const
16
+
17
+ export interface RunningServer {
18
+ magicDir: string
19
+ pid: number
20
+ ppid: number
21
+ command: string
22
+ startedAt?: Date
23
+ /**
24
+ * 启动者已死(ppid 指向 init/systemd)——**残留族**(2026-09-14 新增):
25
+ * 实测成因:IDE/app 退出时通常只杀直接子进程(`npx`),
26
+ * `npm exec → sh -c → tsx → node` 这几代被 reparent 到 systemd 继续存活
27
+ * (同一逻辑 server 5 个进程;旧实例的 server 在 app 重启后仍活了 20+ 分钟)。
28
+ * 这类进程的 stdio 对端已消失,属可回收垃圾;也不应参与新鲜度判定。
29
+ */
30
+ orphan?: boolean
31
+ }
32
+
33
+ export interface FreshnessInfo {
34
+ stale: boolean | "unknown"
35
+ magicDir: string
36
+ processStartedAt?: string
37
+ artifactMtime?: string
38
+ markerPath?: string
39
+ detail: string
40
+ }
41
+
42
+ export interface FreshnessDeps {
43
+ /** 测试注入:ps 输出(`ps -eo pid=,etimes=,args=` 形式) */
44
+ psOutput?: string
45
+ listServers?: () => RunningServer[]
46
+ mtimeOf?: (path: string) => Date | null
47
+ markerExists?: (path: string) => boolean
48
+ now?: () => Date
49
+ }
50
+
51
+ /** 扫描全部 adapter 的运行中 MCP server(解析失败返回空数组,不抛) */
52
+ export function listRunningMcpServers(deps: Pick<FreshnessDeps, "psOutput" | "now"> = {}): RunningServer[] {
53
+ let out = deps.psOutput
54
+ if (out === undefined) {
55
+ try {
56
+ out = execSync("ps -eo pid=,ppid=,etimes=,args=", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
57
+ } catch {
58
+ return []
59
+ }
60
+ }
61
+ const now = (deps.now ?? (() => new Date()))()
62
+ const lines = out.split("\n")
63
+ // 启动者已死的判定依据:ppid 落在 init / systemd 上(reparent 的结果)
64
+ const supervisorPids = new Set<number>()
65
+ const allPids = new Set<number>()
66
+ for (const line of lines) {
67
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/)
68
+ if (!m) continue
69
+ allPids.add(Number(m[1]))
70
+ if (/(^|\/)(systemd|init)(\s|$)/.test(m[4])) supervisorPids.add(Number(m[1]))
71
+ }
72
+ const servers: RunningServer[] = []
73
+ for (const line of lines) {
74
+ if (!line.includes("mcp-server.ts")) continue
75
+ if (line.includes("mcp-restart-notice")) continue // 跳过本脚本自身
76
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/)
77
+ if (!m) continue
78
+ const ppid = Number(m[2])
79
+ const cmd = m[4]
80
+ /*
81
+ * 噪声过滤(2026-09-14 收窄):
82
+ * 旧实现是 `cmd.includes("node_modules") → skip`,把**我们自己的 tsx 启动层**也一并挡掉
83
+ * (`node …/node_modules/.bin/../tsx/dist/cli.mjs <magicDir>/scripts/mcp-server.ts`)——
84
+ * 后果是回收只能杀掉表层 npx/npm,深层 node/tsx 残部继续存活(实测:回收后仍留
85
+ * `135292/135310`、`135897/135913` 两族残部)。
86
+ * 现在只排除「安装副本」:dot 目录直接位于 node_modules 树内
87
+ * (如 `node_modules/<pkg>/.codex/scripts/mcp-server.ts`),其余照常纳入。
88
+ */
89
+ if (/(^|[/\s])node_modules\/(?:[^/\s]+\/)*\.[a-z][a-z0-9-]*\/scripts\/mcp-server\.ts/.test(cmd)) continue
90
+ // magic 段必须以点开头(.codex/.qoder…),且路径后是行尾或参数分隔
91
+ const magic = cmd.match(/(?:^|[/\s])(\.[a-z][a-z0-9-]*)\/scripts\/mcp-server\.ts(?:\s|$)/)
92
+ if (!magic) continue
93
+ const etimes = Number(m[3])
94
+ servers.push({
95
+ magicDir: magic[1],
96
+ pid: Number(m[1]),
97
+ ppid,
98
+ command: cmd,
99
+ startedAt: Number.isFinite(etimes) ? new Date(now.getTime() - etimes * 1000) : undefined,
100
+ orphan: false, // 先按 o 标记,稍后按"整族"传播(见下)
101
+ })
102
+ }
103
+ /*
104
+ * 孤儿按**整族**传播:链上后几代(npm exec / sh -c / tsx / node)的 ppid 指向族内进程,
105
+ * 单看自身 ppid 判不出来 —— 只有族根(其 ppid 不在族内)能反映"启动者是否还在"。
106
+ * 族根判定为孤儿时,整族标记(含 rootPpid 不存在=父进程已消失的情形)。
107
+ */
108
+ const byPid = new Map(servers.map((s) => [s.pid, s]))
109
+ const childrenOf = new Map<number, number[]>()
110
+ for (const s of servers) {
111
+ if (!byPid.has(s.ppid)) continue
112
+ childrenOf.set(s.ppid, [...(childrenOf.get(s.ppid) ?? []), s.pid])
113
+ }
114
+ for (const s of servers) {
115
+ if (byPid.has(s.ppid)) continue // 不是族根
116
+ const rootParentGone = s.ppid === 1 || supervisorPids.has(s.ppid) || !allPids.has(s.ppid)
117
+ if (!rootParentGone) continue
118
+ const stack = [s.pid]
119
+ while (stack.length > 0) {
120
+ const pid = stack.pop() as number
121
+ const node = byPid.get(pid)
122
+ if (!node || node.orphan) continue
123
+ node.orphan = true
124
+ stack.push(...(childrenOf.get(pid) ?? []))
125
+ }
126
+ }
127
+ return servers
128
+ }
129
+
130
+ /**
131
+ * 残留族清单(供 `scripts/mcp-restart-notice.ts` 回收):
132
+ * 只按 adapter 取**全部孤儿 pid**(含 npx/npm/sh/tsx/node 各代),非孤儿一律不动。
133
+ */
134
+ export function orphanPidsToReap(servers: readonly RunningServer[]): number[] {
135
+ return [...new Set(servers.filter((s) => s.orphan).map((s) => s.pid))].sort((a, b) => a - b)
136
+ }
137
+
138
+ /**
139
+ * 按 adapter 聚合:同一 adapter 常同时存在多条进程记录(npx / npm exec / sh -c / tsx 包装链)。
140
+ * 取**最早的启动时间**(最保守判定)合成一条,避免一个逻辑 server 被报成十几条。
141
+ */
142
+ export function earliestPerAdapter(servers: RunningServer[]): Map<string, RunningServer> {
143
+ const out = new Map<string, RunningServer>()
144
+ for (const s of servers) {
145
+ const prev = out.get(s.magicDir)
146
+ if (!prev) {
147
+ out.set(s.magicDir, s)
148
+ continue
149
+ }
150
+ const prevAt = prev.startedAt?.getTime() ?? Number.POSITIVE_INFINITY
151
+ const curAt = s.startedAt?.getTime() ?? Number.POSITIVE_INFINITY
152
+ if (curAt < prevAt) out.set(s.magicDir, s)
153
+ }
154
+ return out
155
+ }
156
+
157
+ /** 判定单个 adapter 的新鲜度(未知一律 "unknown") */
158
+ export function computeFreshness(
159
+ magicDir: string,
160
+ projectRoot: string,
161
+ deps: FreshnessDeps = {},
162
+ ): FreshnessInfo {
163
+ const artifactPath = join(projectRoot, magicDir, "scripts", "mcp-server.ts")
164
+ const markerPath = join(projectRoot, magicDir, RESTART_MARKER_FILE)
165
+ const mtime = deps.mtimeOf
166
+ ? deps.mtimeOf(artifactPath)
167
+ : existsSync(artifactPath)
168
+ ? statSync(artifactPath).mtime
169
+ : null
170
+ const hasMarker = deps.markerExists ? deps.markerExists(markerPath) : existsSync(markerPath)
171
+ const base: Omit<FreshnessInfo, "stale" | "detail"> = {
172
+ magicDir,
173
+ ...(mtime ? { artifactMtime: mtime.toISOString() } : {}),
174
+ ...(hasMarker ? { markerPath } : {}),
175
+ }
176
+ if (!mtime) {
177
+ return { ...base, stale: "unknown", detail: "产物不存在,无法判定新鲜度" }
178
+ }
179
+ const allServers = deps.listServers ? deps.listServers() : listRunningMcpServers()
180
+ // 残留族(启动者已死)不参与新鲜度判定:否则"孤儿进程"会把判定带偏
181
+ // (实测:旧实例的 server 让新鲜度报出错误的 pid / 错误的 stale 值)
182
+ const perAdapter = earliestPerAdapter(allServers.filter((s) => s.magicDir === magicDir && !s.orphan))
183
+ const server = perAdapter.get(magicDir)
184
+ if (!server) {
185
+ return { ...base, stale: false, detail: `${magicDir} 无运行中 server` }
186
+ }
187
+ if (!server.startedAt) {
188
+ return { ...base, stale: "unknown", detail: `进程 pid=${server.pid} 启动时间不可得` }
189
+ }
190
+ const stale = server.startedAt.getTime() < mtime.getTime()
191
+ return {
192
+ ...base,
193
+ processStartedAt: server.startedAt.toISOString(),
194
+ stale,
195
+ detail: stale
196
+ ? `${magicDir} server(pid ${server.pid}) 启动早于产物更新,需重启`
197
+ : `${magicDir} server(pid ${server.pid}) 晚于产物,已最新`,
198
+ }
199
+ }
200
+
201
+ /** 写重启标记(失败返回 null,调用方 fail-open) */
202
+ export function writeRestartRequiredMarker(
203
+ magicDir: string,
204
+ projectRoot: string,
205
+ info: { reason: string; pid?: number; artifactMtime?: string },
206
+ ): string | null {
207
+ const markerPath = join(projectRoot, magicDir, RESTART_MARKER_FILE)
208
+ try {
209
+ writeFileSync(
210
+ markerPath,
211
+ JSON.stringify({ magicDir, at: new Date().toISOString(), ...info }, null, 2),
212
+ "utf-8",
213
+ )
214
+ return markerPath
215
+ } catch {
216
+ return null
217
+ }
218
+ }
219
+
220
+ export function readRestartRequiredMarker(magicDir: string, projectRoot: string): string | null {
221
+ const markerPath = join(projectRoot, magicDir, RESTART_MARKER_FILE)
222
+ try {
223
+ return existsSync(markerPath) ? readFileSync(markerPath, "utf-8") : null
224
+ } catch {
225
+ return null
226
+ }
227
+ }
228
+
229
+ export function clearRestartRequiredMarker(magicDir: string, projectRoot: string): void {
230
+ try {
231
+ rmSync(join(projectRoot, magicDir, RESTART_MARKER_FILE), { force: true })
232
+ } catch {
233
+ /* fail-open */
234
+ }
235
+ }
@@ -29,7 +29,14 @@ import {
29
29
  getEmbeddings,
30
30
  } from "./helpers.js";
31
31
  import { getRuntimeContext } from "../../shared/env.js";
32
- import { extractSpecRef } from "../../shared/dps-spec-ref.js";
32
+ import {
33
+ buildGateCaptureDetail,
34
+ createGateWriterDeps,
35
+ deriveGateRunId,
36
+ formatGateCaptureSummary,
37
+ writeGateMetric,
38
+ GATE_METRIC_TYPE,
39
+ } from "../../shared/memory/metrics/gate-writer.js";
33
40
 
34
41
  export function registerCheckDps(server: ToolRegistrar) {
35
42
  const runtimeContext = getRuntimeContext();
@@ -80,8 +87,10 @@ export function registerCheckDps(server: ToolRegistrar) {
80
87
  let sn = basename(pm).replace(/-plan-v\d+\.md$/, ""),
81
88
  sc = "";
82
89
  if (pc) {
83
- const specRef = extractSpecRef(pc);
84
- if (specRef) sn = specRef.specDir;
90
+ const sr = pc.match(
91
+ /Spec[:|\s`]+\.?(qoder|claude|add|vscode)\/specs\/([^/`\s]+)/,
92
+ );
93
+ if (sr) sn = sr[2];
85
94
  }
86
95
  const sp = join(specsDir, sn, "spec.md");
87
96
  sc = (await readFileSafe(sp)) || "";
@@ -561,6 +570,35 @@ export function registerCheckDps(server: ToolRegistrar) {
561
570
  /* 非阻塞:回写失败不影响 DPS 判定 */
562
571
  }
563
572
 
573
+ // ── Gate 采证(Spec §GateMetric):评分已完成 → 独立提交 MetricSnapshot;
574
+ // runId 由被评分内容派生(同内容重放 = 重复采证);旁路不阻塞评分返回 ──
575
+ const gateSeed = `${pc}\n${sc}\n${rc}`;
576
+ const captureInput = {
577
+ gate: "check_dps" as const,
578
+ planKeyword: pp,
579
+ runId: deriveGateRunId("check_dps", pp, gateSeed),
580
+ metricType: GATE_METRIC_TYPE.check_dps,
581
+ score: dps,
582
+ repository: runtimeContext.projectKey,
583
+ dimensionScores: {
584
+ semantic: semScore,
585
+ entropy: entropyScore,
586
+ cpm: cpmScore,
587
+ structure: structFinal,
588
+ },
589
+ baseline: CFG.THRESHOLD_PASS,
590
+ unit: "score",
591
+ };
592
+ const capture = await writeGateMetric(
593
+ captureInput,
594
+ createGateWriterDeps((prisma as Record<string, unknown>).addMetricSnapshot),
595
+ );
596
+ parts.push(
597
+ "",
598
+ "=== Gate 采证(MetricSnapshot)===",
599
+ ` ${formatGateCaptureSummary(buildGateCaptureDetail(captureInput, capture))}`,
600
+ );
601
+
564
602
  return textResponse(parts.join("\n"));
565
603
  } catch (e) {
566
604
  return errorResponse(