add-coder 0.3.39 → 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 (69) 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 +65 -53
  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/hitl.ts +18 -8
  54. package/templates/core/scripts/mcp-server/tools/index.ts +7 -2
  55. package/templates/core/scripts/mcp-server/tools/memory.ts +235 -1
  56. package/templates/core/scripts/mcp-server/tools/plan-lifecycle.ts +219 -0
  57. package/templates/core/scripts/mcp-server/tools/plan.ts +2 -0
  58. package/templates/core/scripts/mcp-server/tools/widget-probe.ts +53 -0
  59. package/templates/core/scripts/mcp-server.ts +5 -1
  60. package/templates/core/scripts/memory/backfill-search-text.ts +132 -0
  61. package/templates/core/scripts/memory/memory-jobs.ts +124 -0
  62. package/templates/core/scripts/plan-close.ts +85 -0
  63. package/templates/core/skills/add-paradigm/SKILL.md +19 -4
  64. package/templates/core/skills/session-init/SKILL.md +27 -0
  65. package/templates/core/templates/add-route-template-heavyweight.md +2 -0
  66. package/templates/core/templates/add-route-template.md +4 -0
  67. package/templates/core/templates/handoff-multi-round-template.md +2 -0
  68. package/templates/core/templates/simple-plan-template.md +4 -0
  69. package/templates/core/vocabulary/add-governance-vocabulary.md +2 -0
@@ -23,7 +23,7 @@ import {
23
23
  buildHitlProposalMarkdown,
24
24
  applyHitlDecisionToProposal,
25
25
  } from "../shared/hitl-proposal-content.js"
26
- import { HITL_APPROVAL_WIDGET_URI } from "../shared/hitl-ui.js"
26
+ import { getHitlApprovalWidgetUri } from "../shared/hitl-ui.js"
27
27
 
28
28
  // 无类型边界单点(zod 托管):动态加载的 prisma client 在此一次性转为运行期校验的泛型委托
29
29
  const db = {
@@ -49,6 +49,8 @@ function elicitFormSchema(
49
49
 
50
50
  export function registerHitlTools(server: ToolRegistrar) {
51
51
  const runtimeContext = getRuntimeContext()
52
+ // [2026-09-21 修复] widget 资源 URI = 基名 + HTML 内容哈希(宿主把 URI 当缓存键 ⇒ 改 HTML 自动失效)
53
+ const WIDGET_URI = getHitlApprovalWidgetUri()
52
54
 
53
55
  // ═══════════════ 辅助:按安装环境裁决交互模式(caijuehub: hitl-interaction-rules.toml) ═══════════════
54
56
  const _interaction = (() => {
@@ -186,8 +188,8 @@ export function registerHitlTools(server: ToolRegistrar) {
186
188
  }),
187
189
  annotations: { readOnlyHint: true },
188
190
  _meta: {
189
- ui: { resourceUri: HITL_APPROVAL_WIDGET_URI },
190
- "openai/outputTemplate": HITL_APPROVAL_WIDGET_URI,
191
+ ui: { resourceUri: WIDGET_URI },
192
+ "openai/outputTemplate": WIDGET_URI,
191
193
  "openai/toolInvocation/invoking": "正在加载 HITL 审批表…",
192
194
  "openai/toolInvocation/invoked": "HITL 审批表已加载",
193
195
  },
@@ -246,14 +248,22 @@ export function registerHitlTools(server: ToolRegistrar) {
246
248
  dimensions,
247
249
  stale,
248
250
  // 服务端无法得知客户端是否真的渲染了 widget —— 如实标注 unknown,不谎报
249
- ui: { resourceUri: HITL_APPROVAL_WIDGET_URI, rendered: "unknown" as const },
251
+ // requiresHostFlag:宿主侧开关(Codex /experimental → enable_mcp_apps);未开启时 widget 必然加载失败
252
+ ui: {
253
+ resourceUri: WIDGET_URI,
254
+ rendered: "unknown" as const,
255
+ requiresHostFlag: "experimental.enable_mcp_apps",
256
+ },
250
257
  fallback: { markdownPath, htmlPath },
251
258
  }
252
259
  const fallbackHint = markdownPath || htmlPath
260
+ // [2026-09-21 修复] 把「为什么 widget 不显示」的两个确定原因写进返回文本:
261
+ // ① 宿主开关未开(Codex /experimental → enable_mcp_apps);② 工具元数据变更后未重连 MCP server。
262
+ const widgetHint = `widget 若未显示:「This app couldn't be loaded」⇒ ① 确认宿主已开启 /experimental → enable_mcp_apps(Codex);② 重启/重连 MCP server(工具元数据与资源 URI 变更后必须重连)。降级入口:${fallbackHint}`
253
263
  const text =
254
264
  stale.stale === true
255
- ? `已加载 ${planName} round ${current.round} 的 ${dimensions.length} 个审批维度。⚠️ 当前 server 进程早于产物更新(需重启);widget 若未显示,降级入口:${fallbackHint}`
256
- : `已加载 ${planName} round ${current.round} 的 ${dimensions.length} 个审批维度。widget 若未显示,降级入口:${fallbackHint}`
265
+ ? `已加载 ${planName} round ${current.round} 的 ${dimensions.length} 个审批维度。⚠️ 当前 server 进程早于产物更新(需重启);${widgetHint}`
266
+ : `已加载 ${planName} round ${current.round} 的 ${dimensions.length} 个审批维度。${widgetHint}`
257
267
  return {
258
268
  content: [{ type: "text" as const, text }],
259
269
  structuredContent: output,
@@ -266,8 +276,8 @@ export function registerHitlTools(server: ToolRegistrar) {
266
276
  * + 资源 mimeType 为 `text/html;profile=mcp-app` + 资源存在 + dimensions 非空。
267
277
  */
268
278
  _meta: {
269
- ui: { resourceUri: HITL_APPROVAL_WIDGET_URI },
270
- "openai/outputTemplate": HITL_APPROVAL_WIDGET_URI,
279
+ ui: { resourceUri: WIDGET_URI },
280
+ "openai/outputTemplate": WIDGET_URI,
271
281
  },
272
282
  }
273
283
  } catch (e) {
@@ -8,9 +8,11 @@ import { registerGatewayTools } from "./gateway/index.js"
8
8
  import { registerHookEventTools } from "./hook-event-report.js"
9
9
  import { registerHitlTools } from "./hitl.js"
10
10
  import { registerPlanTools } from "./plan.js"
11
+ import { registerPlanLifecycleTools } from "./plan-lifecycle.js"
11
12
  import { registerReviewTools } from "./review.js"
12
13
  import { registerMemoryTools } from "./memory.js"
13
14
  import { registerMemoryCompatTools } from "./memory-compat.js"
15
+ import { registerWidgetProbeTools } from "./widget-probe.js"
14
16
  import { PROJECT_ID } from "../shared/env.js"
15
17
  import type { ToolRegistrar } from "./registrar.js"
16
18
 
@@ -23,6 +25,7 @@ const READ_TOOLS = new Set([
23
25
  "get_project_context", "find_related_docs", "get_db_schema", "query_audit_logs",
24
26
  "plan_status", "review_status", "status_hitl", "contract_status",
25
27
  "render_hitl_approval",
28
+ "probe_widget_render",
26
29
  "check_dps", "check_rahs", "check_add_route_status", "check_spec_sync",
27
30
  "check_add_route_completeness", "check_phase_symmetry", "check_failure_path",
28
31
  "check_add_compliance", "get_hook_events",
@@ -107,8 +110,10 @@ export function registerAllTools(server: McpServer) {
107
110
  registerHookEventTools(registrar) // 1 tool: get_hook_events
108
111
  registerHitlTools(registrar) // 4 tools: create_hitl / update_hitl / status_hitl / render_hitl_approval
109
112
  registerPlanTools(registrar) // 3 tools: plan_track / plan_status / plan_sync
113
+ registerPlanLifecycleTools(registrar) // 1 tool: plan_update(关闭/重开唯一入口;round 与 reopenCycle 语义分离)
110
114
  registerReviewTools(registrar) // 3 tools: review_track / review_status / review_sync
111
- registerMemoryTools(registrar) // 8 tools: propose_memory / recall_memory / get_memory / list_memories / review_memory / resolve_memory / feedback_memory / get_memory_health
115
+ registerMemoryTools(registrar) // 9 tools: propose_memory / recall_memory / get_memory / list_memories / review_memory / resolve_memory / feedback_memory / get_memory_health / refresh_memory_snapshots
112
116
  registerMemoryCompatTools(registrar) // 6 tools: 5 个 v1 弃用门面(append_memory/search_memory/read_memory/link_memory/memory_stats)+ forget_memory(合规清除)
113
- // Total: 44 tools
117
+ registerWidgetProbeTools(registrar) // 1 tool: probe_widget_render(面板渲染诊断,只读)
118
+ // Total: 46 tools
114
119
  }
@@ -1,9 +1,10 @@
1
1
  /*
2
2
  * Memory 治理面 MCP 工具(Spec §8,Plan §6.1/§6.2)
3
3
  *
4
- * 8 个 MVP 工具:
4
+ * 9 个工具:
5
5
  * propose_memory / recall_memory / get_memory / list_memories
6
6
  * review_memory / resolve_memory / feedback_memory / get_memory_health
7
+ * refresh_memory_snapshots(2026-09-21 接线:下游唯一可调用的 L1/L2 快照入口)
7
8
  *
8
9
  * 横切契约:
9
10
  * - 所有写工具与读工具均强制 repositoryRef === runtimeContext.projectKey(ERR_REPOSITORY_MISMATCH)
@@ -12,8 +13,12 @@
12
13
  * - 状态迁移走领域状态机 assertTransition + AuditLog 打点(ADD-5)
13
14
  */
14
15
  import * as z from "zod/v4"
16
+ import { existsSync, readFileSync, statSync } from "node:fs"
17
+ import { join } from "node:path"
15
18
  import type { ToolRegistrar } from "./registrar.js"
16
19
  import { textResponse, errorResponse } from "../shared/response.js"
20
+ import { expandForIndexWithMethod, jiebaUnavailableReason, segmentForMatch } from "../shared/memory/retrieval/cjk-segmenter.js"
21
+ import { checkFtsFingerprint } from "../shared/memory/retrieval/fts-fingerprint.js"
17
22
  import { prisma } from "../shared/prisma.js"
18
23
  import { DATABASE_URL, getRuntimeContext } from "../shared/env.js"
19
24
  import {
@@ -52,6 +57,18 @@ import {
52
57
  } from "../shared/memory/embedding/index.js"
53
58
  import { createPgVectorAdapter } from "../shared/memory/retrieval/vector/pgvector.js"
54
59
  import { createSqliteVecAdapter } from "../shared/memory/retrieval/vector/sqlite-vec.js"
60
+ import {
61
+ refreshL1Snapshot,
62
+ refreshL2Snapshot,
63
+ type SnapshotDeps,
64
+ } from "../shared/memory/jobs/snapshot.js"
65
+ import { drainEvidenceQueue } from "../shared/memory/jobs/evidence-collector.js"
66
+ import {
67
+ recallMode,
68
+ MEMORY_DIR_NAME,
69
+ L1_SNAPSHOT_FILE,
70
+ L1_SNAPSHOT_TTL_MS,
71
+ } from "../shared/memory/switches.js"
55
72
 
56
73
  /** 测试可注入依赖(生产默认从 prisma/DATABASE_URL 构建) */
57
74
  export interface MemoryToolDeps {
@@ -66,6 +83,19 @@ const SCOPE_TYPES = ["ORGANIZATION", "REPOSITORY", "BRANCH", "MODULE", "PATH", "
66
83
  const RECALL_OUTCOMES = ["UNKNOWN", "USED", "USEFUL", "IRRELEVANT", "OUTDATED", "CONTRADICTED", "HARMFUL"] as const
67
84
 
68
85
  export function registerMemoryTools(server: ToolRegistrar, deps: MemoryToolDeps = {}) {
86
+ /**
87
+ * 写入期检索展开(Plan Task 2.4 / Spec §3 R4)。
88
+ * 与查询侧共用 `segmentForMatch`(jieba 主 / bigram 兜底)⇒ 读写同源。
89
+ * **展开为空而原文非空 ⇒ 抛错**(调用方转成写入失败):宁可写不进去,也不能写出"存了但检索不到"的行。
90
+ */
91
+ const requireSearchText = (topic: string, content: string): string => {
92
+ const { text, method } = expandForIndexWithMethod(`${topic} ${content}`)
93
+ if (!text && `${topic}${content}`.trim().length > 0) {
94
+ throw new Error(`检索展开失败:topic/content 非空但 token 为空(分词器=${method}),拒绝写入以免产生不可检索行`)
95
+ }
96
+ return text
97
+ }
98
+
69
99
  const runtimeContext = getRuntimeContext()
70
100
 
71
101
  // 无类型边界单点(zod 托管):动态 client → 运行期校验的泛型委托
@@ -233,6 +263,9 @@ export function registerMemoryTools(server: ToolRegistrar, deps: MemoryToolDeps
233
263
  const created = await memoryDb.create({
234
264
  data: {
235
265
  kind, topic, content,
266
+ // 检索展开(Plan Task 2.4):写入期产出,与查询侧共用同一 tokenization 契约;
267
+ // 展开为空而原文非空 ⇒ 拒绝写入(禁止产生"存进去但检索不到"的行,不静默)
268
+ searchText: requireSearchText(topic, content),
236
269
  summary: (args.summary as string | undefined) ?? null,
237
270
  scopeType, scopeValue, repositoryRef,
238
271
  importance: (args.importance as number | undefined) ?? 0.5,
@@ -642,13 +675,214 @@ export function registerMemoryTools(server: ToolRegistrar, deps: MemoryToolDeps
642
675
  ...lexical.map((l) => l.health()),
643
676
  ])
644
677
 
678
+ // ── 接线自检(2026-09-21 Plan Task 3.1):回答"记忆到底接没接上" ──
679
+ // 只报告不修改:不写用户 env、不改开关,只给可执行的修复指引。
680
+ const memoryDir = join(runtimeContext.projectRoot, runtimeContext.magicDir, MEMORY_DIR_NAME)
681
+ const l1Path = join(memoryDir, L1_SNAPSHOT_FILE)
682
+ const wiringWarnings: Array<{ code: string; detail: string; fixHint: string }> = []
683
+ const mode = recallMode()
684
+
685
+ if (!existsSync(memoryDir)) {
686
+ wiringWarnings.push({
687
+ code: "MEMORY_DIR_MISSING",
688
+ detail: `${runtimeContext.magicDir}/${MEMORY_DIR_NAME}/ 不存在(快照与采证队列均未接线)`,
689
+ fixHint: "运行 `npx add-coder sync`(安装期会自动生成),或调用 refresh_memory_snapshots",
690
+ })
691
+ }
692
+ if (mode === "shadow") {
693
+ const recent = await recallDb.findMany({
694
+ where: { repositoryRef, createdAt: { gte: new Date(Date.now() - 30 * 86400000) } },
695
+ take: 1,
696
+ })
697
+ if (recent.length === 0) {
698
+ wiringWarnings.push({
699
+ code: "SHADOW_ZERO_RECALL",
700
+ detail: "当前档位 shadow 且近 30 天零召回:记忆从未进入会话",
701
+ fixHint: "去掉 ADD_MEMORY_RECALL_MODE 或设为 inject(默认),或直接调用 recall_memory 显式召回",
702
+ })
703
+ }
704
+ }
705
+ if (existsSync(l1Path)) {
706
+ const ageMs = Date.now() - statSync(l1Path).mtimeMs
707
+ if (ageMs > L1_SNAPSHOT_TTL_MS) {
708
+ wiringWarnings.push({
709
+ code: "SNAPSHOT_STALE",
710
+ detail: `L1 快照已过期(${Math.floor(ageMs / 86400000)} 天 > TTL 7 天):注入会被跳过`,
711
+ fixHint: "调用 refresh_memory_snapshots,或运行 {magicDir}/scripts/memory/memory-jobs.ts refresh-l1",
712
+ })
713
+ }
714
+ const content = readFileSync(l1Path, "utf-8")
715
+ if (/本仓库暂无已治理记忆|本次查询未命中已治理记忆/.test(content)) {
716
+ wiringWarnings.push({
717
+ code: "SNAPSHOT_EMPTY",
718
+ detail: "L1 快照已接线但 0 条(ACTIVE=0 或过滤过严):会话启动不会拿到任何记忆",
719
+ fixHint: "先用 propose_memory 沉淀,再经 review_memory → resolve_memory 升为 ACTIVE",
720
+ })
721
+ }
722
+ } else if (existsSync(memoryDir)) {
723
+ wiringWarnings.push({
724
+ code: "SNAPSHOT_MISSING",
725
+ detail: `L1 快照缺失: ${runtimeContext.magicDir}/${MEMORY_DIR_NAME}/${L1_SNAPSHOT_FILE}`,
726
+ fixHint: "调用 refresh_memory_snapshots(或运行 memory-jobs.ts refresh-l1 / npx add-coder sync)",
727
+ })
728
+ }
729
+
730
+ // 词法口径(轮 3 / Task 3.7):主通道用哪套 tokenization + 指纹是否需要重索引。
731
+ // 为什么必须在健康检查里:`searchText` 是写入期产出的 token 串,分词器/词典一变历史行就"索引在、命中不了",
732
+ // 且不报错 ⇒ 只能靠这里显式透出,否则降级是静默的。
733
+ const probe = segmentForMatch("端口契约")
734
+ const fingerprint = checkFtsFingerprint(runtimeContext.projectRoot, runtimeContext.magicDir)
735
+ const lexicalProfile = {
736
+ /** 当前实际生效的分词器:jieba = 主通道;bigram = 兜底(降级) */
737
+ method: probe.method,
738
+ /** jieba 不可用/切分异常的原因(method=bigram 时必非空;禁止静默降级) */
739
+ degradedReason: jiebaUnavailableReason(),
740
+ userDict: probe.userDict ?? false,
741
+ fingerprint: {
742
+ current: fingerprint.current.value,
743
+ recorded: fingerprint.recorded?.fingerprint ?? null,
744
+ requiresReindex: fingerprint.requiresReindex,
745
+ reason: fingerprint.reason,
746
+ },
747
+ }
748
+ if (lexicalProfile.method === "bigram") {
749
+ wiringWarnings.push({
750
+ code: "LEXICAL_BIGRAM_FALLBACK",
751
+ detail: `分词主通道降级为 bigram(${lexicalProfile.degradedReason ?? "原因未知"}):词级判别力下降,长尾召回会变差`,
752
+ fixHint: "安装 @node-rs/jieba(optionalDependency)或检查平台二进制是否可用;生效后 memory:reindex --apply 重算 searchText",
753
+ })
754
+ }
755
+ if (lexicalProfile.fingerprint.requiresReindex) {
756
+ wiringWarnings.push({
757
+ code: "LEXICAL_FINGERPRINT_STALE",
758
+ detail: `检索指纹需重索引:${lexicalProfile.fingerprint.reason}`,
759
+ fixHint: "运行 add-coder memory:reindex --probe 查看明细 → --apply 重建 → 回填脚本重算 searchText",
760
+ })
761
+ }
762
+
645
763
  return textResponse(JSON.stringify({
646
764
  repositoryRef,
647
765
  backlog: { candidate: backlog.CANDIDATE ?? 0, pending: backlog.PENDING ?? 0, byStatus: backlog },
648
766
  leakage: { checkedRecalls: recentRecalls.length, crossRepositorySelections: leakage },
649
767
  providers: { embedding: embeddingHealth },
650
768
  index: ftsHealth ?? { component: "fts", status: "unavailable", detail: "无 lexical adapter" },
769
+ // 词法口径(新增字段):主通道分词器 / 降级原因 / 指纹是否需重索引
770
+ lexicalProfile,
771
+ // 接线状态(新增字段,既有字段保持不变):ok = 四项告警全空
772
+ wiring: {
773
+ status: wiringWarnings.length === 0 ? "ok" : "warning",
774
+ recallMode: mode,
775
+ snapshotPath: l1Path,
776
+ warnings: wiringWarnings,
777
+ },
651
778
  }))
652
779
  } catch (e) { return fail(e) }
653
780
  })
781
+
782
+ // ===== 9. refresh_memory_snapshots(2026-09-21 接线:下游唯一可调用的快照入口) =====
783
+ server.registerTool("refresh_memory_snapshots", {
784
+ description: "刷新记忆快照并消费采证队列:drain evidence-queue → refresh L1(默认)/ L2(需 query)。这是下游唯一可调用的快照入口(init / sync / memory-jobs CLI 复用同一实现)。原子写、幂等、token 预算取 ADD_MEMORY_MAX_TOKENS(默认 600);任一子步失败都会逐条列出(failed=true),不静默吞错。",
785
+ inputSchema: z.object({
786
+ repositoryRef: z.string().describe("仓库标识(必须等于运行时 projectKey)"),
787
+ levels: z.enum(["l1", "l2", "both"]).optional().default("l1").describe("刷新层级:默认 l1"),
788
+ query: z.string().optional().describe("levels 含 l2 时必填:L2 快照的召回意图"),
789
+ drainEvidence: z.boolean().optional().default(true).describe("是否先消费 evidence-queue(默认是)"),
790
+ }),
791
+ }, async (args: Record<string, unknown>, _ctx: unknown) => {
792
+ try {
793
+ const repositoryRef = args.repositoryRef as string
794
+ assertRepository(repositoryRef)
795
+ const levels = (args.levels as "l1" | "l2" | "both" | undefined) ?? "l1"
796
+ const query = args.query as string | undefined
797
+ const drain = (args.drainEvidence as boolean | undefined) ?? true
798
+ if (levels !== "l1" && !query) {
799
+ return errorResponse("levels 含 l2 时必须提供 query(L2 是按 query 的上下文快照),不接受静默降级为 L1")
800
+ }
801
+
802
+ const snapshotDeps: SnapshotDeps = {
803
+ repositoryRef,
804
+ projectDir: runtimeContext.projectRoot,
805
+ magicDir: runtimeContext.magicDir,
806
+ lexical,
807
+ fetchByIds: async (ids) =>
808
+ await memoryDb.findMany({
809
+ where: { id: { in: ids } },
810
+ include: { supersedes: { select: { id: true } } },
811
+ }),
812
+ fetchEvidenceSourceRefs: async (memoryIds) => {
813
+ const links = await linkDb.findMany({ where: { memoryId: { in: memoryIds } } })
814
+ const evIds = [...new Set(links.map((l) => l.evidenceId))]
815
+ const evs = evIds.length > 0 ? await evidenceDb.findMany({ where: { id: { in: evIds } } }) : []
816
+ const refById = new Map(evs.map((e) => [e.id, e.sourceRef]))
817
+ const out = new Map<string, string[]>()
818
+ for (const l of links) {
819
+ const ref = refById.get(l.evidenceId)
820
+ if (!ref) continue
821
+ const arr = out.get(l.memoryId) ?? []
822
+ arr.push(ref)
823
+ out.set(l.memoryId, arr)
824
+ }
825
+ return out
826
+ },
827
+ audit: {
828
+ createRecall: (data) => recallDb.create({ data: data }),
829
+ createRecallItem: (data) => recallItemDb.create({ data: data }),
830
+ },
831
+ }
832
+
833
+ const steps: Array<{ name: string; ok: boolean; detail: string }> = []
834
+
835
+ if (drain) {
836
+ try {
837
+ const r = await drainEvidenceQueue({
838
+ projectDir: runtimeContext.projectRoot,
839
+ magicDir: runtimeContext.magicDir,
840
+ repositoryRef,
841
+ evidenceDb,
842
+ })
843
+ // 行级失败不吞:坏行计入 errors 并原样透出(fail-open 到行,但绝不静默)
844
+ steps.push({
845
+ name: "drain-evidence",
846
+ ok: r.errors.length === 0,
847
+ detail: r.errors.length === 0
848
+ ? `消费 ${r.processed} 条(跳过 ${r.skipped}),offset=${r.newOffset}`
849
+ : `消费 ${r.processed} 条,${r.errors.length} 行失败:${r.errors.slice(0, 3).join("; ")}`,
850
+ })
851
+ } catch (e) {
852
+ steps.push({ name: "drain-evidence", ok: false, detail: e instanceof Error ? e.message : String(e) })
853
+ }
854
+ }
855
+
856
+ const files: Array<{ level: "L1" | "L2"; path: string; itemCount: number; injectedTokens: number }> = []
857
+
858
+ if (levels === "l1" || levels === "both") {
859
+ try {
860
+ const r = await refreshL1Snapshot(snapshotDeps)
861
+ files.push({ level: "L1", path: r.path, itemCount: r.itemCount, injectedTokens: r.injectedTokens })
862
+ steps.push({ name: "refresh-l1", ok: true, detail: `${r.itemCount} 条 / ${r.injectedTokens} tokens → ${r.path}` })
863
+ } catch (e) {
864
+ steps.push({ name: "refresh-l1", ok: false, detail: e instanceof Error ? e.message : String(e) })
865
+ }
866
+ }
867
+
868
+ if (levels === "l2" || levels === "both") {
869
+ try {
870
+ const r = await refreshL2Snapshot(snapshotDeps, query as string, "refresh-l2")
871
+ files.push({ level: "L2", path: r.path, itemCount: r.itemCount, injectedTokens: r.injectedTokens })
872
+ steps.push({ name: "refresh-l2", ok: true, detail: `${r.itemCount} 条 / ${r.injectedTokens} tokens → ${r.path}` })
873
+ } catch (e) {
874
+ steps.push({ name: "refresh-l2", ok: false, detail: e instanceof Error ? e.message : String(e) })
875
+ }
876
+ }
877
+
878
+ const failed = steps.some((s) => !s.ok)
879
+ const payload = { repositoryRef, mode: recallMode(), steps, files, failed }
880
+ const body = JSON.stringify(payload)
881
+ return textResponse(
882
+ failed
883
+ ? `⚠️ 快照刷新存在失败子步(failed=true):${steps.filter((s) => !s.ok).map((s) => `${s.name}: ${s.detail}`).join(" | ")}\n${body}`
884
+ : body,
885
+ )
886
+ } catch (e) { return fail(e) }
887
+ })
654
888
  }
@@ -0,0 +1,219 @@
1
+ /*
2
+ * Plan 生命周期工具(2026-09-21 Plan `add-coder-plan-close-entry-plan-v1`)
3
+ *
4
+ * `plan_update` —— Plan 关闭/重开的**唯一 MCP 入口**(此前库层 transitionPlanLifecycle 齐备但零调用方:
5
+ * add-coder 库 36 条 ACTIVE 从未关闭;farm-agent 的 2 次关闭靠一次性脚本/人工改库,且无审计)。
6
+ *
7
+ * 语义边界(人类 2026-09-21 决策,写死在这里避免混用):
8
+ * - `round` = **施工轮次**(既有 PlanRound):每轮原子实施完成由 plan_round_close 递增,跨重开全局单调;
9
+ * - `reopenCycle` = **重开代数**:每次 CLOSED→REOPENED 递增,只在 `PLAN_REOPENED` 审计里出现,不参与轮次;
10
+ * - `REOPENED` 是**瞬态**:PUL(Policy-Update-Loop)重开后完成新一轮 Step 0 即回 `ACTIVE`;
11
+ * 恢复路径固定为 plan_status + 上一轮 handoff + query_audit_logs(PLAN_REOPENED) → 只补受策略影响的增量 Task。
12
+ */
13
+ import * as z from "zod/v4"
14
+ import type { ToolRegistrar } from "./registrar.js"
15
+ import { textResponse, errorResponse } from "../shared/response.js"
16
+ import { prisma } from "../shared/prisma.js"
17
+ import { getRuntimeContext } from "../shared/env.js"
18
+ import { PlanLifecycleStatusSchema, type PlanLifecycleStatus } from "../shared/plan-lifecycle.js"
19
+ import { transitionPlanLifecycle } from "../shared/plan-lifecycle-mutation.js"
20
+ import { writeDevOperation } from "../shared/dev-operation.js"
21
+
22
+ interface PlanRowLite {
23
+ lifecycle: PlanLifecycleStatus
24
+ revision: number
25
+ totalTasks: number
26
+ doneTasks: number
27
+ }
28
+
29
+ export interface UpdatePlanLifecycleParams {
30
+ planName: string
31
+ target: PlanLifecycleStatus
32
+ reason?: string | null
33
+ policyRef?: string | null
34
+ force?: boolean
35
+ expectedRevision?: number
36
+ }
37
+
38
+ export interface UpdatePlanLifecycleResult {
39
+ ok: boolean
40
+ /** ok=false 时的拒绝原因(前置校验/不存在等),供工具与脚本共用同一措辞 */
41
+ error?: string
42
+ from?: PlanLifecycleStatus
43
+ to?: PlanLifecycleStatus
44
+ revision?: number
45
+ idempotent?: boolean
46
+ forced?: boolean
47
+ blockers?: string[]
48
+ /** lastClosedRound = null 表示未知(无 PlanRound 表且审计里无 ROUND_CLOSED),不编造 0 */
49
+ reopen?: { reopenCycle: number; lastClosedRound: number | null; policyRef: string | null } | null
50
+ audit?: { ok: boolean; detail: string }
51
+ }
52
+
53
+ /**
54
+ * 上次施工轮次(重开时要写进审计:上次停在第几轮)。
55
+ * 优先读 PlanRound 表;当前 add.prisma **未定义 PlanRound 模型**(本轮实测 planRound delegate 不存在)⇒
56
+ * 回退到审计:取该 Plan 最近一条 `ROUND_CLOSED` 的 afterState.round;都没有则返回 null(**显式未知**,不编 0)。
57
+ */
58
+ async function resolveLastClosedRound(
59
+ database: typeof prisma,
60
+ context: ReturnType<typeof getRuntimeContext>,
61
+ planName: string,
62
+ ): Promise<number | null> {
63
+ const delegate = (database as unknown as {
64
+ planRound?: { findFirst(a: unknown): Promise<unknown> }
65
+ }).planRound
66
+ if (delegate) {
67
+ const row = (await delegate.findFirst({
68
+ where: { projectKey: context.projectKey, planName },
69
+ orderBy: { round: "desc" },
70
+ })) as { round?: number } | null
71
+ return row?.round ?? 0
72
+ }
73
+ const rec = (await database.devOperation.findFirst({
74
+ where: {
75
+ projectKey: context.projectKey,
76
+ producerAdapterKey: context.adapterKey,
77
+ planKeyword: planName,
78
+ action: "ROUND_CLOSED",
79
+ },
80
+ orderBy: { createdAt: "desc" },
81
+ })) as { afterState?: { round?: number } } | null
82
+ return typeof rec?.afterState?.round === "number" ? rec.afterState.round : null
83
+ }
84
+
85
+ /**
86
+ * Plan 生命周期更新的**唯一实现**:MCP 工具 `plan_update` 与脚本 `scripts/plan-close.ts` 共用,
87
+ * 避免"工具一份、脚本一份"的漂移(2026-09-21 决策)。
88
+ */
89
+ export async function updatePlanLifecycle(
90
+ database: typeof prisma,
91
+ context: ReturnType<typeof getRuntimeContext>,
92
+ params: UpdatePlanLifecycleParams,
93
+ ): Promise<UpdatePlanLifecycleResult> {
94
+ const { planName, target } = params
95
+ const scope = { projectKey: context.projectKey, adapterKey: context.adapterKey }
96
+ const plan = (await database.planRecord.findFirst({ where: { ...scope, planName } })) as unknown as PlanRowLite | null
97
+ if (!plan) return { ok: false, error: `当前 RuntimeContextKey 下不存在 Plan: ${planName}(先 plan_track 扫描入库)` }
98
+ if (plan.lifecycle === target) {
99
+ return { ok: true, from: plan.lifecycle, to: plan.lifecycle, revision: plan.revision, idempotent: true }
100
+ }
101
+
102
+ const blockers: string[] = []
103
+ if (plan.totalTasks > 0 && plan.doneTasks < plan.totalTasks) {
104
+ blockers.push(`tasks 未完成:${plan.doneTasks}/${plan.totalTasks}`)
105
+ }
106
+ const hitl = (await database.hitlRecord.findFirst({
107
+ where: { ...scope, planName, type: "PLAN" },
108
+ orderBy: { round: "desc" },
109
+ })) as unknown as { status: string } | null
110
+ if (!hitl) blockers.push("缺少 PLAN 类型 HITL 记录(审批链未留痕)")
111
+ else if (hitl.status !== "TONGYI") blockers.push(`最新 PLAN HITL 状态为 ${hitl.status}(需 TONGYI)`)
112
+ if (target === "REOPENED" && !params.policyRef) blockers.push("REOPENED 必须给 policyRef(PUL 追溯:哪条策略触发重开)")
113
+ if (blockers.length > 0 && !params.force) {
114
+ return {
115
+ ok: false,
116
+ error: `前置校验未通过,拒绝 ${planName} → ${target}:\n- ${blockers.join("\n- ")}\n(force 可越过,但必须给 reason)`,
117
+ blockers,
118
+ }
119
+ }
120
+ if (blockers.length > 0 && !params.reason) {
121
+ return { ok: false, error: `force 越过前置校验时必须提供 reason:\n- ${blockers.join("\n- ")}`, blockers }
122
+ }
123
+
124
+ const updated = await transitionPlanLifecycle(database as never, {
125
+ context,
126
+ planName,
127
+ to: target,
128
+ expectedRevision: params.expectedRevision,
129
+ })
130
+
131
+ const reopen = target === "REOPENED"
132
+ ? {
133
+ // DevOperation 的适配器列名是 producerAdapterKey(不是 PlanRecord 的 adapterKey)
134
+ reopenCycle: ((await database.devOperation.count({
135
+ where: {
136
+ projectKey: context.projectKey,
137
+ producerAdapterKey: context.adapterKey,
138
+ planKeyword: planName,
139
+ action: "PLAN_REOPENED",
140
+ },
141
+ })) as unknown as number) + 1,
142
+ lastClosedRound: await resolveLastClosedRound(database, context, planName),
143
+ policyRef: params.policyRef ?? null,
144
+ }
145
+ : null
146
+
147
+ let audit: { ok: boolean; detail: string }
148
+ try {
149
+ await writeDevOperation(database as never, {
150
+ context,
151
+ toolName: "plan_update",
152
+ planKeyword: planName,
153
+ action: `PLAN_${target}`,
154
+ targetType: "PLAN",
155
+ targetId: planName,
156
+ beforeState: { lifecycle: plan.lifecycle, revision: plan.revision },
157
+ afterState: { lifecycle: updated.lifecycle, revision: updated.revision, forced: params.force === true, blockers, ...(reopen ?? {}) },
158
+ reason: params.reason ?? `plan_update ${plan.lifecycle} → ${target}`,
159
+ })
160
+ audit = { ok: true, detail: "DevOperation 已写入(shared/dev-operation.ts)" }
161
+ } catch (e) {
162
+ audit = { ok: false, detail: `DevOperation 写入失败: ${e instanceof Error ? e.message : String(e)}` }
163
+ }
164
+
165
+ return {
166
+ ok: true,
167
+ from: plan.lifecycle,
168
+ to: updated.lifecycle,
169
+ revision: updated.revision,
170
+ forced: params.force === true,
171
+ blockers,
172
+ reopen,
173
+ audit,
174
+ }
175
+ }
176
+
177
+ export function registerPlanLifecycleTools(server: ToolRegistrar) {
178
+ const runtimeContext = getRuntimeContext()
179
+ const scope = { projectKey: runtimeContext.projectKey, adapterKey: runtimeContext.adapterKey }
180
+
181
+ server.registerTool("plan_update", {
182
+ description:
183
+ "更新 Plan 生命周期(关闭/重开的唯一入口,复用状态机 + 事务 + NOTIFY + 统一审计)。\n" +
184
+ "lifecycle 取值即真源枚举(DRAFT/ACTIVE/BLOCKED/REJECTED/CLOSED/REOPENED/ABANDONED)。\n" +
185
+ "注意两个状态机不要混:TONGYI/BOHUI 属审批状态机;ABANDONED = 放弃该 Plan(生命周期决定),且可逆(→ACTIVE 复活)。\n" +
186
+ "驳回语义:REJECTED = 不继续(BOHUI 落此态),后续可 CLOSED(归档)/ REOPENED(重启)/ ABANDONED(放弃)。\n" +
187
+ "幂等:已是目标态 → idempotent=true(不 bump revision、不写审计)。\n" +
188
+ "前置校验(默认严格):tasks 全部完成 + 最新 PLAN HITL 为 TONGYI;force 可越过但必须给 reason(会写进审计)。\n" +
189
+ "REOPENED = 瞬态重开(PUL 场景):reopenCycle 递增、policyRef 记录触发策略;round(施工轮次)不被重置。",
190
+ inputSchema: z.object({
191
+ planName: z.string().describe("Plan 名称"),
192
+ lifecycle: PlanLifecycleStatusSchema.optional().describe("目标生命周期状态(7 值,含 ABANDONED)"),
193
+ reason: z.string().optional().describe("原因(force 必填;会写入审计)"),
194
+ policyRef: z.string().optional().describe("PUL:触发重开的策略/规则引用(REOPENED 必填)"),
195
+ force: z.boolean().optional().describe("越过前置校验(必须同时给 reason)"),
196
+ expectedRevision: z.number().optional().describe("乐观锁:revision 不匹配则拒绝(并行会话防撞)"),
197
+ }),
198
+ }, async (args: Record<string, unknown>) => {
199
+ try {
200
+ const planName = String(args.planName ?? "")
201
+ if (!planName) return errorResponse("planName 必填")
202
+ const target = args.lifecycle as PlanLifecycleStatus | undefined
203
+ if (!target) return errorResponse("未传 lifecycle:本工具只做生命周期更新(进度刷新请用 plan_track)")
204
+ const reason = (args.reason as string | undefined)?.trim() || null
205
+ const policyRef = (args.policyRef as string | undefined)?.trim() || null
206
+ const force = args.force === true
207
+ const expectedRevision = args.expectedRevision as number | undefined
208
+
209
+ // 单一实现:编排(前置校验 + 迁移 + PUL 元数据 + 审计)在 updatePlanLifecycle,与脚本共用
210
+ const result = await updatePlanLifecycle(prisma, runtimeContext, {
211
+ planName, target, reason, policyRef, force, expectedRevision,
212
+ })
213
+ if (!result.ok) return errorResponse(result.error ?? "plan_update 失败")
214
+ return textResponse(JSON.stringify(result, null, 2))
215
+ } catch (e) {
216
+ return errorResponse(`plan_update 失败: ${e instanceof Error ? e.message : String(e)}`)
217
+ }
218
+ })
219
+ }
@@ -11,6 +11,8 @@ import { getRuntimeContext } from "../shared/env.js"
11
11
  // 口径单一真源:checklist 的 [T]/[R] 统计直接复用校验层,避免 tracker 与校验器两套口径漂移
12
12
  import { checklistStats } from "../../../validation/validators/checklist.js"
13
13
  import { assertPathInRuntimeScope } from "../shared/runtime-context.js"
14
+ // 注:Plan 生命周期工具(plan_update)落在 tools/plan-lifecycle.ts,与本文件同族但独立注册;
15
+ // 此处不引入 transitionPlanLifecycle / writeDevOperation,避免"同能力两处实现"的错觉。
14
16
  import { resolvePlanStatus } from "../shared/plan-lifecycle.js"
15
17
  import { createPrismaPlanStatusStore } from "../shared/plan-status-store.js"
16
18
  import {