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.
- package/dist/index.js +21 -3
- package/package.json +3 -3
- package/templates/.add-coder-src-hash.json +95 -41
- package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/claude/hooks/session-start.mjs +65 -1
- package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/codex/hooks/session-start.mjs +65 -1
- package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/qoder/hooks/session-start.mjs +67 -1
- package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/trae/hooks/session-start.mjs +65 -1
- package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/vscode/hooks/session-start.mjs +65 -1
- package/templates/core/governance/doc-format-guard.ts +29 -112
- package/templates/core/governance/post-tool-router.ts +33 -1
- package/templates/core/governance/prompt-router.ts +47 -0
- package/templates/core/governance/session-start-guard.ts +48 -1
- package/templates/core/prisma/add.prisma +203 -0
- package/templates/core/scripts/db-ensure.sh +61 -2
- package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
- package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
- package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
- package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
- package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
- package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
- package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
- package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +41 -3
- package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
- package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +2 -2
- package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
- package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
- package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
- package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
- package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
- package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
- package/templates/core/scripts/mcp-server.ts +36 -0
- package/templates/core/templates/checklist-template.md +13 -0
- package/templates/core/templates/review-implementation-template.md +24 -0
- package/templates/core/templates/review-template.md +16 -0
- package/templates/core/validation/index.ts +136 -0
- package/templates/core/validation/policy.ts +91 -0
- package/templates/core/validation/registry.ts +61 -0
- package/templates/core/validation/schema-validator.ts +277 -0
- package/templates/core/validation/validators/add-route.ts +32 -0
- package/templates/core/validation/validators/checklist.ts +48 -0
- package/templates/core/validation/validators/handoff.ts +46 -0
- package/templates/core/validation/validators/hitl.ts +22 -0
- package/templates/core/validation/validators/index.ts +52 -0
- package/templates/core/validation/validators/plan.ts +20 -0
- package/templates/core/validation/validators/report.ts +16 -0
- package/templates/core/validation/validators/review.ts +30 -0
- package/templates/core/validation/validators/spec.ts +25 -0
- package/templates/core/validation/validators/tasks.ts +40 -0
- package/templates/core/validation/validators/types.ts +32 -0
- package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
- package/templates/core/scripts/mcp-server/shared/dps-spec-ref.ts +0 -17
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 反馈统计(Plan rank-calibration Task 1.1 / Spec §1 §FeedbackStats)
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(**这是三工具分工的第一环**):只做**聚合**,不做拟合、不做权重建议。
|
|
5
|
+
* 通道归属由调用方提供(从 Recall 审计的 scoreBreakdown/fusedChannels + 各通道位次推导),
|
|
6
|
+
* 本模块不猜测来源 —— 猜错通道会让后续拟合学到错的信号。
|
|
7
|
+
*
|
|
8
|
+
* 统计口径(Spec §1):
|
|
9
|
+
* - 有用 = USED | USEFUL;无用 = IRRELEVANT | OUTDATED | CONTRADICTED | HARMFUL;
|
|
10
|
+
* - **UNKNOWN 及其他未知取值不计入分母**(避免用未知样本稀释/污染有用率);
|
|
11
|
+
* - 样本数不足 → status="insufficient"(**此时不得据此改权重**);
|
|
12
|
+
* - 无样本的通道返回 total=0 / usefulRate=0,不用默认值伪装。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type FeedbackChannel = "lexical" | "vector"
|
|
16
|
+
|
|
17
|
+
export const USEFUL_OUTCOMES = ["USED", "USEFUL"] as const
|
|
18
|
+
export const NOT_USEFUL_OUTCOMES = ["IRRELEVANT", "OUTDATED", "CONTRADICTED", "HARMFUL"] as const
|
|
19
|
+
export const DEFAULT_MIN_SAMPLES = 5
|
|
20
|
+
|
|
21
|
+
export interface FeedbackSample {
|
|
22
|
+
memoryId: string
|
|
23
|
+
/** 该条在最终排序中的位次(null = 未入选) */
|
|
24
|
+
rank: number | null
|
|
25
|
+
outcome: string
|
|
26
|
+
/** 检出该条的通道集合(调用方从召回审计推导,可多通道) */
|
|
27
|
+
channels: readonly FeedbackChannel[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChannelStat {
|
|
31
|
+
useful: number
|
|
32
|
+
total: number
|
|
33
|
+
usefulRate: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChannelOutcomeStats {
|
|
37
|
+
/** 可分类样本数(分母:排除 UNKNOWN 等未分类 outcome) */
|
|
38
|
+
samples: number
|
|
39
|
+
status: "ok" | "insufficient"
|
|
40
|
+
byChannel: Record<FeedbackChannel, ChannelStat>
|
|
41
|
+
byRankBucket: { rank: 1 | 2 | 3 | 4 | 5; useful: number; total: number; usefulRate: number }[]
|
|
42
|
+
/** 未分类 outcome 的数量(可审计:有多少样本被排除在分母外) */
|
|
43
|
+
unclassified: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classify(outcome: string): "useful" | "not-useful" | "unclassified" {
|
|
47
|
+
if ((USEFUL_OUTCOMES as readonly string[]).includes(outcome)) return "useful"
|
|
48
|
+
if ((NOT_USEFUL_OUTCOMES as readonly string[]).includes(outcome)) return "not-useful"
|
|
49
|
+
return "unclassified"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rate(useful: number, total: number): number {
|
|
53
|
+
return total === 0 ? 0 : Number((useful / total).toFixed(4))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function aggregateFeedback(
|
|
57
|
+
samples: readonly FeedbackSample[],
|
|
58
|
+
opts: { minSamples?: number } = {},
|
|
59
|
+
): ChannelOutcomeStats {
|
|
60
|
+
const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES
|
|
61
|
+
|
|
62
|
+
const byChannel: Record<FeedbackChannel, ChannelStat> = {
|
|
63
|
+
lexical: { useful: 0, total: 0, usefulRate: 0 },
|
|
64
|
+
vector: { useful: 0, total: 0, usefulRate: 0 },
|
|
65
|
+
}
|
|
66
|
+
const buckets = [1, 2, 3, 4, 5].map((rank) => ({ rank: rank as 1 | 2 | 3 | 4 | 5, useful: 0, total: 0 }))
|
|
67
|
+
|
|
68
|
+
let classified = 0
|
|
69
|
+
let unclassified = 0
|
|
70
|
+
|
|
71
|
+
for (const sample of samples) {
|
|
72
|
+
const kind = classify(sample.outcome)
|
|
73
|
+
if (kind === "unclassified") {
|
|
74
|
+
unclassified++
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
classified++
|
|
78
|
+
const useful = kind === "useful"
|
|
79
|
+
for (const channel of new Set(sample.channels)) {
|
|
80
|
+
if (!byChannel[channel]) continue
|
|
81
|
+
byChannel[channel].total++
|
|
82
|
+
if (useful) byChannel[channel].useful++
|
|
83
|
+
}
|
|
84
|
+
if (sample.rank != null && sample.rank >= 1 && sample.rank <= 5) {
|
|
85
|
+
const bucket = buckets[sample.rank - 1]
|
|
86
|
+
bucket.total++
|
|
87
|
+
if (useful) bucket.useful++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
samples: classified,
|
|
93
|
+
status: classified < minSamples ? "insufficient" : "ok",
|
|
94
|
+
byChannel: {
|
|
95
|
+
lexical: { ...byChannel.lexical, usefulRate: rate(byChannel.lexical.useful, byChannel.lexical.total) },
|
|
96
|
+
vector: { ...byChannel.vector, usefulRate: rate(byChannel.vector.useful, byChannel.vector.total) },
|
|
97
|
+
},
|
|
98
|
+
byRankBucket: buckets.map((b) => ({ ...b, usefulRate: rate(b.useful, b.total) })),
|
|
99
|
+
unclassified,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 单元状态解析(Plan rank-calibration Task 2.1.9 / Spec §2.2 + §2.3)
|
|
3
|
+
*
|
|
4
|
+
* 架构约束:单元(Plan 业务闭包)的状态是**既有事实**,必须读出来——不能手填、不能另建状态表。
|
|
5
|
+
* closed = add-route 全部产出项 [x] 且存在 ROUND_CLOSED devlog
|
|
6
|
+
* in-flight = add-route 存在但仍有 [ ],或缺少 ROUND_CLOSED(并发协议下的飞行中单元)
|
|
7
|
+
* unknown = 连 add-route 都没有(§2.3 视为缺制品,样本不可作训练依据)
|
|
8
|
+
*
|
|
9
|
+
* 同时产出**单元引用表**(refs):报告按单元分层时直接引用这些制品,不复制内容(§2.3)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { validate as coreValidate, type ValidationIssue } from "../../../../../validation/index.js"
|
|
13
|
+
|
|
14
|
+
export interface UnitRefs {
|
|
15
|
+
planPath?: string
|
|
16
|
+
addRoutePath?: string
|
|
17
|
+
handoffPath?: string
|
|
18
|
+
specsDir?: string
|
|
19
|
+
planRecordId?: string
|
|
20
|
+
roundClosedAuditId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UnitState {
|
|
24
|
+
planKeyword: string
|
|
25
|
+
state: "closed" | "in-flight" | "unknown"
|
|
26
|
+
/**
|
|
27
|
+
* 诊断量(**不参与 closed 判定**):add-route 勾选与事实的差距。
|
|
28
|
+
* 勾选是过程进度,不是收敛证据——用它判 closed 会把"过程"当"结果"。
|
|
29
|
+
*/
|
|
30
|
+
documentationLag: { openSteps: number; totalSteps: number; lagging: boolean }
|
|
31
|
+
/** 封口四要素(Spec §2.2:四项合取) */
|
|
32
|
+
evidence: {
|
|
33
|
+
roundClosed: boolean
|
|
34
|
+
handoff: boolean
|
|
35
|
+
acceptance: boolean
|
|
36
|
+
planStatus: boolean
|
|
37
|
+
}
|
|
38
|
+
/** handoff 合规校验明细(来自 core 校验层;存在 ≠ 合规,Spec §2.2) */
|
|
39
|
+
handoffValidation?: { type: "handoff.single" | "handoff.multi"; ok: boolean; issues: ValidationIssue[] }
|
|
40
|
+
/** 缺失的既有制品(§2.3:缺证据的单元不可作为训练依据) */
|
|
41
|
+
missingArtifacts: string[]
|
|
42
|
+
refs: UnitRefs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UnitStateDeps {
|
|
46
|
+
projectRoot: string
|
|
47
|
+
magicDir: string
|
|
48
|
+
/** 读文件内容(不存在返回 null);测试注入 */
|
|
49
|
+
readFile?: (absPath: string) => string | null
|
|
50
|
+
/** 列 plans 目录下的文件名(不存在返回 []);测试注入 */
|
|
51
|
+
listDir?: (absDir: string) => string[]
|
|
52
|
+
/**
|
|
53
|
+
* 列 plans 目录下的**相对路径**(含日期子目录,如 `2026-09/12/x.md`);生产路径必须用这个 ——
|
|
54
|
+
* ADD 约定制品按 `plans/{YYYY-MM}/{DD}/` 分层,扁平列目录会漏掉全部真实 Plan(2026-09-13 真实单元自检暴露)。
|
|
55
|
+
*/
|
|
56
|
+
listFiles?: (absDir: string) => string[]
|
|
57
|
+
/** 是否存在(specs 目录判断);测试注入 */
|
|
58
|
+
exists?: (absPath: string) => boolean
|
|
59
|
+
/** 查 ROUND_CLOSED devlog(测试注入) */
|
|
60
|
+
findRoundClosed?: (planKeyword: string) => Promise<{ id: string } | null>
|
|
61
|
+
/** 查 PlanRecord(测试注入) */
|
|
62
|
+
findPlanRecord?: (planKeyword: string) => Promise<{
|
|
63
|
+
id: string
|
|
64
|
+
lifecycle?: string
|
|
65
|
+
doneTasks?: number
|
|
66
|
+
totalTasks?: number
|
|
67
|
+
} | null>
|
|
68
|
+
/**
|
|
69
|
+
* handoff 合规校验(默认 = core 校验层,schema 真源)。
|
|
70
|
+
* 注入点仅供测试;生产路径必须走 core,不得再自解析模板(Spec §2.2/§2.4)。
|
|
71
|
+
*/
|
|
72
|
+
validateHandoff?: (input: {
|
|
73
|
+
type: "handoff.single" | "handoff.multi"
|
|
74
|
+
path: string
|
|
75
|
+
expectRounds: number
|
|
76
|
+
}) => { ok: boolean; issues: ValidationIssue[] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 轮次数推导(既有制品读取,不新建状态)。
|
|
81
|
+
*
|
|
82
|
+
* 语义:取各制品中 `轮次 N` 的最大编号;**编号从 0 起 → +1,从 1 起 → 不变**。
|
|
83
|
+
* 例:`轮次 0..3` → 4 轮;`轮次 1` → 1 轮(单轮)。
|
|
84
|
+
* 输入建议按真源优先级传入:[planContent, tasksContent, addRouteContent]
|
|
85
|
+
* (tasks.md 的「轮次拓扑」是最稳定的轮次真源;add-route 的轮次常写在代码块内)。
|
|
86
|
+
*/
|
|
87
|
+
export function planRounds(...contents: readonly string[]): number {
|
|
88
|
+
const nums: number[] = []
|
|
89
|
+
for (const content of contents) {
|
|
90
|
+
for (const m of content.matchAll(/轮次\s*(\d+)/g)) nums.push(Number(m[1]))
|
|
91
|
+
}
|
|
92
|
+
if (nums.length === 0) return 1
|
|
93
|
+
const max = Math.max(...nums)
|
|
94
|
+
const min = Math.min(...nums)
|
|
95
|
+
return min === 0 ? max + 1 : max
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 统计 add-route 产出项勾选情况(只认 `- [ ]` / `- [x]`) */
|
|
99
|
+
export function countSteps(content: string): { open: number; total: number } {
|
|
100
|
+
let open = 0
|
|
101
|
+
let total = 0
|
|
102
|
+
for (const line of content.split("\n")) {
|
|
103
|
+
if (/^- \[ \]/.test(line)) {
|
|
104
|
+
open++
|
|
105
|
+
total++
|
|
106
|
+
} else if (/^- \[[xX]\]/.test(line)) {
|
|
107
|
+
total++
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { open, total }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function resolveUnitState(
|
|
114
|
+
planKeyword: string,
|
|
115
|
+
deps: UnitStateDeps,
|
|
116
|
+
): Promise<UnitState> {
|
|
117
|
+
const plansDir = `${deps.projectRoot}/${deps.magicDir}/plans`
|
|
118
|
+
const specsDirRoot = `${deps.projectRoot}/${deps.magicDir}/specs`
|
|
119
|
+
const listDir = deps.listFiles ?? deps.listDir ?? (() => [])
|
|
120
|
+
const readFile = deps.readFile ?? (() => null)
|
|
121
|
+
const exists = deps.exists ?? (() => false)
|
|
122
|
+
|
|
123
|
+
const has = (name: string, must: string[]): boolean =>
|
|
124
|
+
must.every((m) => name.toLowerCase().includes(m.toLowerCase()))
|
|
125
|
+
|
|
126
|
+
const files = listDir(plansDir)
|
|
127
|
+
// ★ 匹配基准是 plan **base** 名(去掉 -plan-vN):兄弟制品不带 plan 段——
|
|
128
|
+
// Plan=`{base}-plan-v1.md`、add-route=`{base}-add-route-v1.md`、handoff=`{base}-handoff-v1.md`。
|
|
129
|
+
// 用完整 planKeyword 去匹配会漏掉后两者(2026-09-13 真实单元自检暴露)。
|
|
130
|
+
const base = planKeyword.replace(/-plan-v\d+$/i, "")
|
|
131
|
+
const planRel = files.find((f) => has(f, [base, "-plan-v"]) && !f.includes(".hitl"))
|
|
132
|
+
const addRouteRel = files.find((f) => has(f, [base, "add-route"]))
|
|
133
|
+
const handoffRel = files.find((f) => has(f, [base, "handoff"]))
|
|
134
|
+
|
|
135
|
+
const refs: UnitRefs = {}
|
|
136
|
+
const missingArtifacts: string[] = []
|
|
137
|
+
|
|
138
|
+
if (planRel) refs.planPath = `${deps.magicDir}/plans/${planRel}`
|
|
139
|
+
else missingArtifacts.push("plan")
|
|
140
|
+
if (addRouteRel) refs.addRoutePath = `${deps.magicDir}/plans/${addRouteRel}`
|
|
141
|
+
else missingArtifacts.push("add-route")
|
|
142
|
+
if (handoffRel) refs.handoffPath = `${deps.magicDir}/plans/${handoffRel}`
|
|
143
|
+
else missingArtifacts.push("handoff")
|
|
144
|
+
|
|
145
|
+
if (exists(`${specsDirRoot}/${base}`)) refs.specsDir = `${deps.magicDir}/specs/${base}`
|
|
146
|
+
else missingArtifacts.push("specs")
|
|
147
|
+
|
|
148
|
+
const [roundClosed, planRecord] = await Promise.all([
|
|
149
|
+
deps.findRoundClosed ? deps.findRoundClosed(planKeyword) : Promise.resolve(null),
|
|
150
|
+
deps.findPlanRecord ? deps.findPlanRecord(planKeyword) : Promise.resolve(null),
|
|
151
|
+
])
|
|
152
|
+
if (roundClosed) refs.roundClosedAuditId = roundClosed.id
|
|
153
|
+
else missingArtifacts.push("round-closed")
|
|
154
|
+
if (planRecord) refs.planRecordId = planRecord.id
|
|
155
|
+
else missingArtifacts.push("plan-record")
|
|
156
|
+
|
|
157
|
+
const addRouteContent = addRouteRel ? (readFile(`${plansDir}/${addRouteRel}`) ?? "") : ""
|
|
158
|
+
const { open, total } = countSteps(addRouteContent)
|
|
159
|
+
|
|
160
|
+
// 验收证据:checklist 存在且 [T] 项无未勾选(缺证据的单元不可作训练依据)
|
|
161
|
+
const checklistAbs = refs.specsDir ? `${deps.projectRoot}/${refs.specsDir}/checklist.md` : ""
|
|
162
|
+
const checklistContent = checklistAbs ? (readFile(checklistAbs) ?? "") : ""
|
|
163
|
+
const unverifiedT = (checklistContent.match(/^- \[ \] \[T\]/gm) ?? []).length
|
|
164
|
+
const verifiedT = (checklistContent.match(/^- \[[xX]\] \[T\]/gm) ?? []).length
|
|
165
|
+
const acceptance = unverifiedT + verifiedT > 0 && unverifiedT === 0
|
|
166
|
+
|
|
167
|
+
// PlanRecord 状态:存在且进度收敛(done === total 且 total > 0)
|
|
168
|
+
const planStatus =
|
|
169
|
+
!!planRecord && (planRecord.totalTasks ?? 0) > 0 && planRecord.doneTasks === planRecord.totalTasks
|
|
170
|
+
|
|
171
|
+
const evidence = {
|
|
172
|
+
roundClosed: !!roundClosed,
|
|
173
|
+
handoff: false, // 由下方 handoff 合规校验填充(存在 ≠ 合规)
|
|
174
|
+
acceptance,
|
|
175
|
+
planStatus,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 轮次真源:Plan 正文 + tasks.md(若有)+ add-route,取最大编号推导
|
|
179
|
+
const specBaseFromRefs = refs.specsDir ? `${deps.projectRoot}/${refs.specsDir}` : ""
|
|
180
|
+
const tasksContent = specBaseFromRefs ? (readFile(`${specBaseFromRefs}/tasks.md`) ?? "") : ""
|
|
181
|
+
const planContentForRounds = planRel ? (readFile(`${plansDir}/${planRel}`) ?? "") : ""
|
|
182
|
+
|
|
183
|
+
// handoff 合规校验:走 core 校验层(schema 真源);缺文件即不合规
|
|
184
|
+
let handoffValidation: UnitState["handoffValidation"]
|
|
185
|
+
if (handoffRel) {
|
|
186
|
+
const rounds = planRounds(planContentForRounds, tasksContent, addRouteContent)
|
|
187
|
+
const type = rounds > 1 ? "handoff.multi" : "handoff.single"
|
|
188
|
+
const absPath = `${plansDir}/${handoffRel}`
|
|
189
|
+
const runner =
|
|
190
|
+
deps.validateHandoff ??
|
|
191
|
+
((input: { type: "handoff.single" | "handoff.multi"; path: string; expectRounds: number }) => {
|
|
192
|
+
// core 校验层读 schema 真源;本模块不自解析模板(Spec §2.2/§2.4)
|
|
193
|
+
return coreValidate({
|
|
194
|
+
type: input.type,
|
|
195
|
+
path: input.path,
|
|
196
|
+
hook: "manual",
|
|
197
|
+
expectRounds: input.expectRounds,
|
|
198
|
+
projectRoot: deps.projectRoot,
|
|
199
|
+
magicDir: deps.magicDir,
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
const result = runner({ type, path: absPath, expectRounds: rounds })
|
|
203
|
+
handoffValidation = { type, ok: result.ok, issues: result.issues }
|
|
204
|
+
evidence.handoff = result.ok
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let state: UnitState["state"]
|
|
208
|
+
if (!addRouteRel && !planRel) state = "unknown"
|
|
209
|
+
else if (evidence.roundClosed && evidence.handoff && evidence.acceptance && evidence.planStatus) {
|
|
210
|
+
state = "closed"
|
|
211
|
+
} else {
|
|
212
|
+
state = "in-flight"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
planKeyword,
|
|
217
|
+
state,
|
|
218
|
+
documentationLag: { openSteps: open, totalSteps: total, lagging: total > 0 && open > 0 },
|
|
219
|
+
evidence,
|
|
220
|
+
...(handoffValidation ? { handoffValidation } : {}),
|
|
221
|
+
missingArtifacts,
|
|
222
|
+
refs,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 冲突检测(Plan §3 治理队列 / §7.2 同级冲突不得静默覆盖)
|
|
3
|
+
*
|
|
4
|
+
* 首版为保守启发式(无 embedding 依赖):同 scope 且高文本相似的
|
|
5
|
+
* CONSTRAINT/DECISION 互为疑似冲突,必须进入 review,由人裁决。
|
|
6
|
+
*/
|
|
7
|
+
import { charBigrams, jaccardSimilarity } from "./dedup.js"
|
|
8
|
+
|
|
9
|
+
export interface Conflict {
|
|
10
|
+
memoryId: string
|
|
11
|
+
topic: string
|
|
12
|
+
similarity: number
|
|
13
|
+
reason: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ConflictCandidate {
|
|
17
|
+
id?: string
|
|
18
|
+
kind: string
|
|
19
|
+
topic: string
|
|
20
|
+
content: string
|
|
21
|
+
scopeType: string
|
|
22
|
+
scopeValue: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConflictOptions {
|
|
26
|
+
/** 相似度阈值(字符二元组 Jaccard),默认 0.6 */
|
|
27
|
+
threshold?: number
|
|
28
|
+
/** 参与冲突判定的 kind 集合 */
|
|
29
|
+
kinds?: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONFLICT_KINDS = ["CONSTRAINT", "DECISION", "CONVENTION"] as const
|
|
33
|
+
|
|
34
|
+
export function detectConflicts(
|
|
35
|
+
candidate: ConflictCandidate,
|
|
36
|
+
actives: ConflictCandidate[],
|
|
37
|
+
opts: ConflictOptions = {},
|
|
38
|
+
): Conflict[] {
|
|
39
|
+
const threshold = opts.threshold ?? 0.6
|
|
40
|
+
const kinds = opts.kinds ?? DEFAULT_CONFLICT_KINDS
|
|
41
|
+
if (!kinds.includes(candidate.kind)) return []
|
|
42
|
+
const candVec = charBigrams(candidate.topic + " " + candidate.content)
|
|
43
|
+
const out: Conflict[] = []
|
|
44
|
+
for (const m of actives) {
|
|
45
|
+
if (m.id && m.id === candidate.id) continue
|
|
46
|
+
if (!kinds.includes(m.kind)) continue
|
|
47
|
+
if (m.scopeType !== candidate.scopeType || m.scopeValue !== candidate.scopeValue) continue
|
|
48
|
+
const sim = jaccardSimilarity(candVec, charBigrams(m.topic + " " + m.content))
|
|
49
|
+
if (sim >= threshold) {
|
|
50
|
+
out.push({
|
|
51
|
+
memoryId: m.id ?? "",
|
|
52
|
+
topic: m.topic,
|
|
53
|
+
similarity: Math.round(sim * 1000) / 1000,
|
|
54
|
+
reason: `同 scope(${m.scopeType}:${m.scopeValue}) 存在高相似 ${m.kind},疑似冲突,需人工裁决`,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out.sort((a, b) => b.similarity - a.similarity)
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 去重与内容规范化(Plan §4.4)
|
|
3
|
+
* contentHash = sha256(normalize(text));幂等键 = repositoryRef + contentHash + scopeType + scopeValue
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "crypto"
|
|
6
|
+
|
|
7
|
+
/** 规范化:NFKC(全半角折叠)→ 小写 → 折叠全部空白为单空格 → trim */
|
|
8
|
+
export function normalizeContent(s: string): string {
|
|
9
|
+
return s.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function contentHash(s: string): string {
|
|
13
|
+
return createHash("sha256").update(normalizeContent(s), "utf8").digest("hex")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 幂等键(与 AddMemory 的 @@unique 约束一一对应) */
|
|
17
|
+
export function memoryIdempotencyKey(input: {
|
|
18
|
+
repositoryRef: string
|
|
19
|
+
content: string
|
|
20
|
+
scopeType: string
|
|
21
|
+
scopeValue: string
|
|
22
|
+
}): string {
|
|
23
|
+
return [
|
|
24
|
+
input.repositoryRef,
|
|
25
|
+
contentHash(input.content),
|
|
26
|
+
input.scopeType,
|
|
27
|
+
input.scopeValue,
|
|
28
|
+
].join("|")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 字符二元组集合(冲突检测 / 近重复合并的轻量相似度基础,无需 embedding) */
|
|
32
|
+
export function charBigrams(s: string): Set<string> {
|
|
33
|
+
const n = normalizeContent(s)
|
|
34
|
+
const out = new Set<string>()
|
|
35
|
+
for (let i = 0; i < n.length - 1; i++) out.add(n.slice(i, i + 2))
|
|
36
|
+
if (n.length === 1) out.add(n)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
41
|
+
if (a.size === 0 && b.size === 0) return 1
|
|
42
|
+
let inter = 0
|
|
43
|
+
for (const x of a) if (b.has(x)) inter++
|
|
44
|
+
return inter / (a.size + b.size - inter || 1)
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Memory 子系统稳定错误码(Plan §12.3:MCP 错误采用稳定错误码,不能只返回自由文本)
|
|
3
|
+
* 契约:code 字符串冻结,变更需走 Review;message 可携带上下文细节。
|
|
4
|
+
*/
|
|
5
|
+
export const MEMORY_ERROR = {
|
|
6
|
+
ERR_REPOSITORY_MISMATCH: "调用方 repositoryRef 与运行时上下文不一致,越权访问被拒绝",
|
|
7
|
+
ERR_ORG_SCOPE_DISABLED: "ORGANIZATION scope 首版禁用(Plan §17-7 定案)",
|
|
8
|
+
ERR_ILLEGAL_TRANSITION: "非法状态迁移",
|
|
9
|
+
ERR_EVIDENCE_REQUIRED: "进入 ACTIVE 必须至少关联一条 Evidence",
|
|
10
|
+
ERR_APPROVAL_REQUIRED: "进入 ACTIVE 必须提供 approvedBy/approvedAt",
|
|
11
|
+
ERR_SUPERSESSION_INVALID: "supersede 需要有效的 supersededById 且 repository/scope 兼容",
|
|
12
|
+
ERR_INVARIANT: "字段不变量违反(如 importance/confidence 越出 [0,1])",
|
|
13
|
+
ERR_FTS_UNAVAILABLE: "FTS 能力不可用且无法降级",
|
|
14
|
+
ERR_SECRET_DETECTED: "内容命中密钥/凭证模式,拒写",
|
|
15
|
+
ERR_NOT_FOUND: "记录不存在",
|
|
16
|
+
ERR_EMBEDDING_DISABLED: "EmbeddingProvider=none,向量能力未启用",
|
|
17
|
+
ERR_DIMENSION_MISMATCH: "嵌入维度与真源不一致(模型元数据 vs 声明维度),拒绝写入脏向量",
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export type MemoryErrorCode = keyof typeof MEMORY_ERROR
|
|
21
|
+
|
|
22
|
+
export class MemoryError extends Error {
|
|
23
|
+
readonly code: MemoryErrorCode
|
|
24
|
+
constructor(code: MemoryErrorCode, detail?: string) {
|
|
25
|
+
super(detail ? `${code}: ${MEMORY_ERROR[code]}(${detail})` : `${code}: ${MEMORY_ERROR[code]}`)
|
|
26
|
+
this.name = "MemoryError"
|
|
27
|
+
this.code = code
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isMemoryError(e: unknown): e is MemoryError {
|
|
32
|
+
return e instanceof MemoryError
|
|
33
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Handoff Digest 候选构造(Plan §3.1 轮 2 / Spec §3 §HandoffDigest)
|
|
3
|
+
*
|
|
4
|
+
* 纯函数:输入 HANDOFF Evidence 行 → 输出可落库的 HANDOFF_DIGEST 候选提案。
|
|
5
|
+
* 三条不变量:
|
|
6
|
+
* 1. **绝不直接 ACTIVE**——提案只带 status=CANDIDATE 语义(由调用方落库时体现);
|
|
7
|
+
* 2. **幂等**——dedupKey 由「来源引用 + 摘要内容」派生,同一 evidence 重放得到同一键;
|
|
8
|
+
* 3. **内容是可验证结论**,不是裸数值或空串:excerpt 为空时显式标注「摘要待人工补充」。
|
|
9
|
+
*/
|
|
10
|
+
import { contentHash, normalizeContent } from "./dedup.js"
|
|
11
|
+
|
|
12
|
+
export const HANDOFF_DIGEST_KIND = "HANDOFF_DIGEST" as const
|
|
13
|
+
|
|
14
|
+
/** 摘要正文最大字符数(超出截断并标注) */
|
|
15
|
+
export const HANDOFF_EXCERPT_MAX = 300
|
|
16
|
+
|
|
17
|
+
export interface HandoffDigestInput {
|
|
18
|
+
/** handoff 文档路径或引用(evidence.sourceRef) */
|
|
19
|
+
handoffRef: string
|
|
20
|
+
/** evidence 摘要原文 */
|
|
21
|
+
excerpt: string
|
|
22
|
+
/** 关联 Plan 关键词(evidence.planKeyword,可为空) */
|
|
23
|
+
planKeyword?: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HandoffDigestProposal {
|
|
27
|
+
kind: typeof HANDOFF_DIGEST_KIND
|
|
28
|
+
topic: string
|
|
29
|
+
content: string
|
|
30
|
+
scopeType: "PLAN" | "REPOSITORY"
|
|
31
|
+
scopeValue: string
|
|
32
|
+
contentHash: string
|
|
33
|
+
dedupKey: string
|
|
34
|
+
metadata: {
|
|
35
|
+
handoffRef: string
|
|
36
|
+
planKeyword: string | null
|
|
37
|
+
excerptTruncated: boolean
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 从 handoff 引用提取主题名(去目录、去扩展名) */
|
|
42
|
+
export function handoffTopicOf(handoffRef: string): string {
|
|
43
|
+
const base = handoffRef.split(/[\\/]/).pop() ?? handoffRef
|
|
44
|
+
const stem = base.replace(/\.(md|markdown|txt)$/i, "")
|
|
45
|
+
return stem.length > 0 ? stem : handoffRef
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 构造 HANDOFF_DIGEST 候选提案。
|
|
50
|
+
*
|
|
51
|
+
* scope 选择:有 planKeyword → PLAN scope(历史语义,默认召回需显式放行);
|
|
52
|
+
* 无 planKeyword → REPOSITORY scope(可被常规召回命中)。
|
|
53
|
+
*/
|
|
54
|
+
export function buildHandoffDigest(
|
|
55
|
+
input: HandoffDigestInput,
|
|
56
|
+
opts: { repositoryRef: string; maxExcerpt?: number },
|
|
57
|
+
): HandoffDigestProposal {
|
|
58
|
+
const maxExcerpt = opts.maxExcerpt ?? HANDOFF_EXCERPT_MAX
|
|
59
|
+
const topic = handoffTopicOf(input.handoffRef)
|
|
60
|
+
const normalizedExcerpt = normalizeContent(input.excerpt ?? "")
|
|
61
|
+
const planKeyword = input.planKeyword && input.planKeyword.trim().length > 0
|
|
62
|
+
? input.planKeyword.trim()
|
|
63
|
+
: null
|
|
64
|
+
|
|
65
|
+
const truncated = normalizedExcerpt.length > maxExcerpt
|
|
66
|
+
const body = truncated
|
|
67
|
+
? `${normalizedExcerpt.slice(0, maxExcerpt)}…(已截断)`
|
|
68
|
+
: normalizedExcerpt
|
|
69
|
+
|
|
70
|
+
const content =
|
|
71
|
+
body.length > 0
|
|
72
|
+
? `交接摘要 ${topic}:${body}(来源 ${input.handoffRef})`
|
|
73
|
+
: `交接文档 ${input.handoffRef} 已归档,摘要待人工补充(自动摘要为空)。`
|
|
74
|
+
|
|
75
|
+
const scopeType: "PLAN" | "REPOSITORY" = planKeyword ? "PLAN" : "REPOSITORY"
|
|
76
|
+
const scopeValue = planKeyword ?? opts.repositoryRef
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
kind: HANDOFF_DIGEST_KIND,
|
|
80
|
+
topic,
|
|
81
|
+
content,
|
|
82
|
+
scopeType,
|
|
83
|
+
scopeValue,
|
|
84
|
+
contentHash: contentHash(content),
|
|
85
|
+
dedupKey: contentHash(`${HANDOFF_DIGEST_KIND}|${input.handoffRef}|${normalizedExcerpt}`),
|
|
86
|
+
metadata: {
|
|
87
|
+
handoffRef: input.handoffRef,
|
|
88
|
+
planKeyword,
|
|
89
|
+
excerptTruncated: truncated,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 指标到知识的转换(Plan §10)
|
|
3
|
+
*
|
|
4
|
+
* entropy/code quality/Gate score 只写 AddMetricSnapshot;单个数值不能自动成为长期文本记忆。
|
|
5
|
+
* 候选生成需满足:同 scope 异常连续达阈值 / 多独立证据指向同根因 / 显著偏离基线且有可解释事件。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface MetricPoint {
|
|
9
|
+
repositoryRef: string
|
|
10
|
+
metricType: string
|
|
11
|
+
value: number
|
|
12
|
+
baseline?: number | null
|
|
13
|
+
planKeyword?: string | null
|
|
14
|
+
measuredAt: Date
|
|
15
|
+
sourceRef: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MetricCandidateRule {
|
|
19
|
+
/** 同 scope 连续异常阈值(默认 3 次) */
|
|
20
|
+
streakThreshold: number
|
|
21
|
+
/** 显著偏离基线的相对幅度(默认 20%) */
|
|
22
|
+
deviationRatio: number
|
|
23
|
+
/** 判定"异常"的方向:高于基线为坏(higher-worse)或低于基线为坏(lower-worse) */
|
|
24
|
+
direction: "higher-worse" | "lower-worse"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_METRIC_RULE: MetricCandidateRule = {
|
|
28
|
+
streakThreshold: 3,
|
|
29
|
+
deviationRatio: 0.2,
|
|
30
|
+
direction: "lower-worse",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MetricCandidateProposal {
|
|
34
|
+
topic: string
|
|
35
|
+
content: string
|
|
36
|
+
evidenceSourceRefs: string[]
|
|
37
|
+
rule: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isAnomaly(p: MetricPoint, rule: MetricCandidateRule): boolean {
|
|
41
|
+
if (p.baseline == null) return false
|
|
42
|
+
const dev = Math.abs(p.value - p.baseline) / (Math.abs(p.baseline) || 1)
|
|
43
|
+
if (dev < rule.deviationRatio) return false
|
|
44
|
+
return rule.direction === "lower-worse" ? p.value < p.baseline : p.value > p.baseline
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 检测「同 scope 连续异常」:按时间排序后尾部连续异常数 ≥ streakThreshold 时生成候选建议。
|
|
49
|
+
* 纯函数;是否真正创建 Candidate 由调用方(consolidation job / 人工)决定。
|
|
50
|
+
*/
|
|
51
|
+
export function detectAnomalyStreak(
|
|
52
|
+
points: MetricPoint[],
|
|
53
|
+
rule: MetricCandidateRule = DEFAULT_METRIC_RULE,
|
|
54
|
+
): MetricCandidateProposal | null {
|
|
55
|
+
if (points.length < rule.streakThreshold) return null
|
|
56
|
+
const sorted = [...points].sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime())
|
|
57
|
+
let streak = 0
|
|
58
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
59
|
+
if (isAnomaly(sorted[i], rule)) streak++
|
|
60
|
+
else break
|
|
61
|
+
}
|
|
62
|
+
if (streak < rule.streakThreshold) return null
|
|
63
|
+
const last = sorted[sorted.length - 1]
|
|
64
|
+
const streakPoints = sorted.slice(-streak)
|
|
65
|
+
return {
|
|
66
|
+
topic: `${last.metricType} 连续 ${streak} 次显著偏离基线`,
|
|
67
|
+
content:
|
|
68
|
+
`指标 ${last.metricType} 在最近 ${streak} 次测量中连续偏离基线超过 ${rule.deviationRatio * 100}%` +
|
|
69
|
+
`(baseline=${streakPoints[0].baseline}, 最新值=${last.value})。` +
|
|
70
|
+
`建议人工审核是否形成可验证结论后再转为长期记忆。`,
|
|
71
|
+
evidenceSourceRefs: streakPoints.map((p) => p.sourceRef),
|
|
72
|
+
rule: `anomaly-streak>=${rule.streakThreshold} deviation>=${rule.deviationRatio} ${rule.direction}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Gate 执行后生成 sourceRef(Review P1 #2 回流:{gate}:{planKeyword}:{runId}) */
|
|
77
|
+
export function metricSourceRef(gate: string, planKeyword: string, runId: string): string {
|
|
78
|
+
return `${gate}:${planKeyword}:${runId}`
|
|
79
|
+
}
|