kcode-pi 0.1.34 → 0.1.38

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 (43) hide show
  1. package/README.md +10 -10
  2. package/dist/cli/kcode.js +3 -3
  3. package/dist/context/project-context.js +4 -5
  4. package/dist/harness/prompt-policy.d.ts +19 -0
  5. package/dist/harness/prompt-policy.js +206 -0
  6. package/dist/harness/types.d.ts +74 -0
  7. package/dist/harness/types.js +16 -0
  8. package/dist/product/profile.d.ts +20 -0
  9. package/dist/product/profile.js +103 -0
  10. package/docs/CHANGELOG.md +90 -2
  11. package/docs/COMMAND_REFERENCE.md +27 -12
  12. package/docs/DEVELOPMENT.md +3 -3
  13. package/docs/EVIDENCE_AND_GATES.md +15 -8
  14. package/docs/HARNESS_WORKFLOW.md +32 -12
  15. package/docs/KCODE_DISTRIBUTION.md +7 -7
  16. package/docs/PRODUCT_PROFILE.md +9 -9
  17. package/docs/TROUBLESHOOTING.md +8 -8
  18. package/docs/USER_GUIDE.md +10 -10
  19. package/extensions/kingdee-harness.ts +141 -86
  20. package/extensions/kingdee-header.ts +1 -1
  21. package/extensions/kingdee-subagents.ts +1 -1
  22. package/extensions/kingdee-tools.ts +44 -44
  23. package/package.json +1 -1
  24. package/src/cli/kcode.ts +3 -3
  25. package/src/context/project-context.ts +4 -5
  26. package/src/harness/artifacts.ts +6 -7
  27. package/src/harness/data-source-policy.ts +346 -0
  28. package/src/harness/delegation.ts +28 -23
  29. package/src/harness/gates.ts +16 -1
  30. package/src/harness/messages.ts +65 -11
  31. package/src/harness/path-policy.ts +1 -0
  32. package/src/harness/plan-steps.ts +3 -3
  33. package/src/harness/prompt-policy.ts +227 -0
  34. package/src/harness/prompt.ts +12 -16
  35. package/src/harness/question-memory.ts +220 -0
  36. package/src/harness/repair.ts +18 -3
  37. package/src/harness/state.ts +93 -6
  38. package/src/harness/types.ts +19 -0
  39. package/src/official/kingdee-skills.ts +4 -4
  40. package/src/product/profile.ts +2 -2
  41. package/src/rules/checker.ts +27 -27
  42. package/src/tools/build-debug.ts +5 -5
  43. package/src/tools/sdk-signature.ts +4 -4
@@ -0,0 +1,220 @@
1
+ import type { ActiveRun, KdFact, KdQuestion } from "./types.ts";
2
+ import { canonicalFactLabel } from "./prompt-policy.ts";
3
+
4
+ export function formatQuestionMemory(run: ActiveRun): string {
5
+ const questions = Array.isArray(run.questions) ? run.questions : [];
6
+ const facts = currentFactsForRun(run);
7
+ const rejected = rejectedFactsForRun(run);
8
+ if (questions.length === 0 && facts.length === 0 && rejected.length === 0) return "无。";
9
+
10
+ const answered = questions.filter((question) => question.status === "answered");
11
+ const open = questions.filter((question) => question.status === "open");
12
+ const sections: string[] = [];
13
+
14
+ if (facts.length > 0) {
15
+ sections.push("## 当前结构化事实", formatCurrentFacts(run));
16
+ }
17
+ if (rejected.length > 0) {
18
+ sections.push("## 已否定候选事实", rejected.map((fact) => `- ${fact.label}:${fact.value}`).join("\n"));
19
+ }
20
+ if (answered.length > 0) {
21
+ sections.push("## 已回答问题", answered.map(formatAnsweredQuestion).join("\n"));
22
+ }
23
+ if (open.length > 0) {
24
+ sections.push("## 未回答问题", open.map(formatOpenQuestion).join("\n"));
25
+ }
26
+
27
+ return sections.join("\n\n") || "无。";
28
+ }
29
+
30
+ export function formatAnsweredQuestionFacts(run: ActiveRun): string {
31
+ return formatCurrentFacts(run);
32
+ }
33
+
34
+ export function formatCurrentFacts(run: ActiveRun): string {
35
+ const lines = currentFactsForRun(run).map((fact) => `- ${fact.label}:${fact.value}`);
36
+ return lines.length > 0 ? dedupe(lines).join("\n") : "无。";
37
+ }
38
+
39
+ export function currentFactsForRun(run: ActiveRun): KdFact[] {
40
+ return latestFactsByKey(persistedFactsForRun(run).filter((fact) => fact.status === "current" && fact.value.trim()));
41
+ }
42
+
43
+ export function rejectedFactsForRun(run: ActiveRun): KdFact[] {
44
+ return latestFactsByKey(persistedFactsForRun(run).filter((fact) => fact.status === "rejected" && fact.value.trim()));
45
+ }
46
+
47
+ export function currentFactForLabel(run: ActiveRun, label: string): KdFact | undefined {
48
+ const canonical = canonicalFactLabel(label) ?? label;
49
+ const key = factKey(canonical);
50
+ return currentFactsForRun(run).find((fact) => fact.key === key);
51
+ }
52
+
53
+ export function hasCurrentFactForLabel(run: ActiveRun, label: string): boolean {
54
+ return Boolean(currentFactForLabel(run, label));
55
+ }
56
+
57
+ export function factFromAnsweredQuestion(question: KdQuestion, now = new Date().toISOString()): KdFact | undefined {
58
+ if (question.status !== "answered" || !question.factLabel) return undefined;
59
+ const answer = question.answer?.trim();
60
+ if (!answer) return undefined;
61
+ if (isPlaceholderAnswer(answer)) return undefined;
62
+ const label = canonicalFactLabel(question.factLabel);
63
+ if (!label) return undefined;
64
+ if (question.proposedFactValue) {
65
+ if (!isAffirmativeOnlyAnswer(answer) && !isNegativeAnswer(answer)) return undefined;
66
+ const value = question.proposedFactValue.trim();
67
+ if (!value) return undefined;
68
+ return {
69
+ key: factKey(label),
70
+ label,
71
+ value,
72
+ status: isAffirmativeOnlyAnswer(answer) ? "current" : "rejected",
73
+ source: "question",
74
+ sourceQuestionId: question.id,
75
+ reason: question.reason,
76
+ createdAt: now,
77
+ updatedAt: now,
78
+ };
79
+ }
80
+
81
+ if (isConcreteAnswer(answer)) {
82
+ return {
83
+ key: factKey(label),
84
+ label,
85
+ value: answer,
86
+ status: "current",
87
+ source: "question",
88
+ sourceQuestionId: question.id,
89
+ reason: question.reason,
90
+ createdAt: now,
91
+ updatedAt: now,
92
+ };
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ export function questionAskBlockReason(run: ActiveRun, input: { factLabel?: string }): string | undefined {
98
+ const rawLabel = input.factLabel?.trim();
99
+ if (!rawLabel) return undefined;
100
+ const label = canonicalFactLabel(rawLabel);
101
+ if (!label) return `未知 factLabel:${rawLabel}。必须使用 KCode 集中定义的实现契约、数据源上下文或第三方对接事实标签。`;
102
+ const key = factKey(label);
103
+ const open = (Array.isArray(run.questions) ? run.questions : []).find(
104
+ (question) => question.status === "open" && question.blocking && question.factLabel && factKey(question.factLabel) === key,
105
+ );
106
+ if (open) {
107
+ return `事实标签 ${label} 已有未回答问题 ${open.id},禁止重复提问。下一步:获取用户答案后用 kd_question action=answer id=${open.id} answer=<答案> 记录。`;
108
+ }
109
+ const fact = currentFactForLabel(run, label);
110
+ if (fact) {
111
+ return `事实标签 ${label} 已确认为 ${fact.value},禁止重复提问。用户明确更正时使用 kd_question action=revise factLabel="${label}" answer="<新值>" reason="<更正原因>"。`;
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ export function invalidFactValueReason(value: string): string | undefined {
117
+ return isInvalidFactValue(value) ? "事实值不是可核验内容,不能使用待确认、未知、按实际环境、TODO/TBD、单独确认/否定或口头确认语解除门禁。" : undefined;
118
+ }
119
+
120
+ export function invalidConfirmationAnswerReason(value: string): string | undefined {
121
+ return isAffirmativeOnlyAnswer(value) || isNegativeAnswer(value) ? undefined : "确认题答案必须是明确肯定或明确否定,不能使用口头确认语或其他模糊回复。";
122
+ }
123
+
124
+ export function factKey(label: string): string {
125
+ return label.trim().toLowerCase().replace(/\s+/g, "");
126
+ }
127
+
128
+ export function upsertFact(facts: KdFact[] | undefined, next: KdFact): KdFact[] {
129
+ const existing = Array.isArray(facts) ? facts : [];
130
+ const now = next.updatedAt || new Date().toISOString();
131
+ const normalizedNext = { ...next, key: factKey(next.label), updatedAt: now, createdAt: next.createdAt || now };
132
+ const updated = existing.map((fact) => ({ ...fact }));
133
+
134
+ if (normalizedNext.status === "current") {
135
+ for (const fact of updated) {
136
+ if (fact.key === normalizedNext.key && fact.status === "current" && fact.value !== normalizedNext.value) {
137
+ fact.status = "superseded";
138
+ fact.updatedAt = now;
139
+ fact.supersededBy = normalizedNext.value;
140
+ }
141
+ }
142
+ }
143
+
144
+ const duplicate = updated.find(
145
+ (fact) =>
146
+ fact.key === normalizedNext.key &&
147
+ fact.value === normalizedNext.value &&
148
+ fact.status === normalizedNext.status &&
149
+ fact.sourceQuestionId === normalizedNext.sourceQuestionId,
150
+ );
151
+ if (duplicate) {
152
+ duplicate.updatedAt = now;
153
+ duplicate.reason = normalizedNext.reason ?? duplicate.reason;
154
+ return updated;
155
+ }
156
+
157
+ return [...updated, normalizedNext];
158
+ }
159
+
160
+ function isConcreteAnswer(answer: string): boolean {
161
+ return !isInvalidFactValue(answer);
162
+ }
163
+
164
+ function isAffirmativeOnlyAnswer(answer: string): boolean {
165
+ return /^(是|是的|对|正确|确认|yes|true)$/i.test(answer.trim());
166
+ }
167
+
168
+ function isNegativeAnswer(answer: string): boolean {
169
+ return /^(否|不是|不对|错误|no|false)(\s|。|,|,|;|;|$)/i.test(answer.trim());
170
+ }
171
+
172
+ function isPlaceholderAnswer(answer: string): boolean {
173
+ return /^(待确认|未知|不清楚|未提供|无|没有|none|unknown|todo|tbd|n\/a|按实际|按实际环境|以后再说)(\s|。|,|,|;|;|$)/i.test(answer.trim());
174
+ }
175
+
176
+ function isAcknowledgementOnlyAnswer(answer: string): boolean {
177
+ return /^(可以|好的|好|行|嗯|嗯嗯|收到|明白|ok|okay|行的)(\s|。|,|,|;|;|$)/i.test(answer.trim());
178
+ }
179
+
180
+ function isInvalidFactValue(answer: string): boolean {
181
+ return isPlaceholderAnswer(answer) || isAffirmativeOnlyAnswer(answer) || isNegativeAnswer(answer) || isAcknowledgementOnlyAnswer(answer);
182
+ }
183
+
184
+ function formatAnsweredQuestion(question: KdQuestion): string {
185
+ return `- ${question.id}:${question.question} => ${question.answer?.trim() ?? ""}`;
186
+ }
187
+
188
+ function formatOpenQuestion(question: KdQuestion): string {
189
+ return [
190
+ `- ${question.id}${question.blocking ? " [blocking]" : ""}:${question.question}`,
191
+ question.reason ? ` - 原因:${question.reason}` : undefined,
192
+ question.choices?.length ? ` - 选项:${question.choices.join(" | ")}` : undefined,
193
+ ].filter(Boolean).join("\n");
194
+ }
195
+
196
+ function dedupe(values: string[]): string[] {
197
+ return [...new Set(values)];
198
+ }
199
+
200
+ function latestFactsByKey(facts: KdFact[]): KdFact[] {
201
+ const byKey = new Map<string, KdFact>();
202
+ for (const fact of facts) {
203
+ const current = byKey.get(fact.key);
204
+ if (!current || (fact.updatedAt || fact.createdAt).localeCompare(current.updatedAt || current.createdAt) >= 0) {
205
+ byKey.set(fact.key, fact);
206
+ }
207
+ }
208
+ return [...byKey.values()];
209
+ }
210
+
211
+ function persistedFactsForRun(run: ActiveRun): KdFact[] {
212
+ return (Array.isArray(run.facts) ? run.facts : []).flatMap(normalizePersistedFact);
213
+ }
214
+
215
+ function normalizePersistedFact(fact: KdFact): KdFact[] {
216
+ const label = canonicalFactLabel(fact.label);
217
+ const value = typeof fact.value === "string" ? fact.value.trim() : "";
218
+ if (!label || !value || invalidFactValueReason(value)) return [];
219
+ return [{ ...fact, key: factKey(label), label, value }];
220
+ }
@@ -40,6 +40,14 @@ export function recordVerifyResult(cwd: string, run: ActiveRun, input: VerifyRes
40
40
  }
41
41
 
42
42
  const normalized = normalizeInput(input);
43
+ const inputProblem = verifyInputBlockReason(normalized);
44
+ if (inputProblem) {
45
+ return {
46
+ status: "rejected",
47
+ run,
48
+ message: inputProblem,
49
+ };
50
+ }
43
51
  if (normalized.exitCode === 0) {
44
52
  return recordVerifyPass(cwd, run, normalized);
45
53
  }
@@ -99,7 +107,7 @@ function recordVerifyFailure(cwd: string, run: ActiveRun, input: NormalizedVerif
99
107
  {
100
108
  id: `Q-${String((run.questions?.length ?? 0) + 1).padStart(3, "0")}`,
101
109
  phase: "verify",
102
- question: `验证失败已达到 ${maxAttempts} 轮。是否允许继续修复,或需要回到 plan 调整范围?`,
110
+ question: `验证失败已达到 ${maxAttempts} 轮。选择继续修复、回到 plan 调整范围或停止。`,
103
111
  reason: `最近失败证据:${evidence}`,
104
112
  choices: ["继续修复", "回到 plan", "停止"],
105
113
  blocking: true,
@@ -161,7 +169,7 @@ function appendExecuteRepairRecord(cwd: string, run: ActiveRun, evidence: string
161
169
  "",
162
170
  `- 修复轮次:${attempts}/${maxAttempts}`,
163
171
  `- 失败证据:${evidence}`,
164
- "- 下一步:读取失败证据,分析失败原因,只在 PLAN.md 批准文件内修复;如果需要改未批准文件,回到 plan 更新计划。",
172
+ "- 下一步:读取失败证据,分析失败原因,只在 PLAN.md 批准文件内修复;涉及未批准文件时回到 plan 更新计划。",
165
173
  "",
166
174
  ].join("\n");
167
175
  writeArtifact(cwd, run, "execute", `${existing.trimEnd()}\n${section}`);
@@ -184,7 +192,7 @@ function formatVerifyEvidence(title: string, input: NormalizedVerifyResultInput)
184
192
  function normalizeInput(input: VerifyResultInput): NormalizedVerifyResultInput {
185
193
  const exitCode = typeof input.exitCode === "number" ? input.exitCode : Number(input.exitCode);
186
194
  return {
187
- command: normalizeText(input.command).trim() || "unknown",
195
+ command: normalizeText(input.command).trim(),
188
196
  exitCode: Number.isFinite(exitCode) ? Math.trunc(exitCode) : 1,
189
197
  stdout: normalizeText(input.stdout),
190
198
  stderr: normalizeText(input.stderr),
@@ -202,6 +210,13 @@ function verifyResultBlockReason(run: ActiveRun): string | undefined {
202
210
  return `kd_verify_result 只能在 verify 阶段记录,或在自动修复循环的 execute 阶段记录。当前阶段:${run.phase}。`;
203
211
  }
204
212
 
213
+ function verifyInputBlockReason(input: NormalizedVerifyResultInput): string | undefined {
214
+ if (!input.command || /^(unknown|待确认|未知|todo|tbd|n\/a)$/i.test(input.command)) {
215
+ return "kd_verify_result 缺少真实验证命令,拒绝记录证据。下一步:运行 PLAN.md 中声明的验证命令;外部系统或人工验证必须记录用户提供的命令、环境、测试数据和证据来源。";
216
+ }
217
+ return undefined;
218
+ }
219
+
205
220
  function closeRepairQuestions(run: ActiveRun, evidence: string): void {
206
221
  if (!Array.isArray(run.questions)) return;
207
222
  const now = new Date().toISOString();
@@ -1,10 +1,12 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import type { ActiveRun, KdPhase, KdQuestion, KdRisk, KdRiskSource } from "./types.ts";
2
+ import type { ActiveRun, KdFact, KdPhase, KdQuestion, KdRisk, KdRiskSource } from "./types.ts";
3
3
  import { PHASE_ARTIFACTS, isKdPhase, nextPhase } from "./types.ts";
4
4
  import { activeRunPath, kdDir, runStatePath, runsDir } from "./paths.ts";
5
5
  import { defaultArtifactContent, ensureArtifact, ensureRunDirectories, writeArtifact } from "./artifacts.ts";
6
6
  import { canEnterPhase, inspectGate } from "./gates.ts";
7
7
  import { profileForProduct, resolveProductProfile } from "../product/profile.ts";
8
+ import { factFromAnsweredQuestion, factKey, invalidConfirmationAnswerReason, invalidFactValueReason, upsertFact } from "./question-memory.ts";
9
+ import { canonicalFactLabel } from "./prompt-policy.ts";
8
10
 
9
11
  export function readActiveRun(cwd: string): ActiveRun | undefined {
10
12
  const path = activeRunPath(cwd);
@@ -81,9 +83,10 @@ export function createActiveRun(cwd: string, goal: string, productInput?: string
81
83
  profile,
82
84
  artifacts: {},
83
85
  questions: [],
86
+ facts: [],
84
87
  gate: {
85
88
  passed: false,
86
- reason: "进入 spec 前必须先完成 CONTEXT.md 并确认产品画像",
89
+ reason: "进入 spec 前必须完成 CONTEXT.md 并确认产品画像",
87
90
  checkedAt: new Date().toISOString(),
88
91
  },
89
92
  };
@@ -98,14 +101,25 @@ export function createActiveRun(cwd: string, goal: string, productInput?: string
98
101
  export function addQuestion(
99
102
  cwd: string,
100
103
  run: ActiveRun,
101
- input: { question: string; reason?: string; choices?: string[]; blocking?: boolean },
104
+ input: { question: string; reason?: string; factLabel?: string; proposedFactValue?: string; choices?: string[]; blocking?: boolean },
102
105
  ): KdQuestion {
103
106
  const existing = Array.isArray(run.questions) ? run.questions : [];
107
+ const canonicalLabel = input.factLabel?.trim() ? canonicalFactLabel(input.factLabel) : undefined;
108
+ const duplicate = existing.find(
109
+ (question) =>
110
+ question.status === "open" &&
111
+ question.blocking &&
112
+ ((canonicalLabel && question.factLabel && factKey(question.factLabel) === factKey(canonicalLabel)) ||
113
+ normalizeQuestion(question.question) === normalizeQuestion(input.question)),
114
+ );
115
+ if (duplicate) return duplicate;
104
116
  const question: KdQuestion = {
105
117
  id: createQuestionId(existing.length + 1),
106
118
  phase: run.phase,
107
119
  question: input.question.trim(),
108
120
  reason: input.reason?.trim() || undefined,
121
+ factLabel: canonicalLabel,
122
+ proposedFactValue: input.proposedFactValue?.trim() || undefined,
109
123
  choices: input.choices?.map((choice) => choice.trim()).filter(Boolean),
110
124
  blocking: input.blocking ?? true,
111
125
  status: "open",
@@ -120,15 +134,49 @@ export function addQuestion(
120
134
  export function answerQuestion(cwd: string, run: ActiveRun, id: string, answer: string): KdQuestion | undefined {
121
135
  const question = (Array.isArray(run.questions) ? run.questions : []).find((item) => item.id === id);
122
136
  if (!question) return undefined;
137
+ if (question.status !== "open") return undefined;
138
+ const trimmed = answer.trim();
139
+ if (!trimmed) return undefined;
140
+ if (question.factLabel && question.proposedFactValue && invalidConfirmationAnswerReason(trimmed)) return undefined;
141
+ if (question.factLabel && !question.proposedFactValue && invalidFactValueReason(trimmed)) return undefined;
123
142
  question.status = "answered";
124
- question.answer = answer.trim();
143
+ question.answer = trimmed;
125
144
  question.answeredAt = new Date().toISOString();
145
+ applyQuestionFact(run, question);
126
146
  applyRepairQuestionAnswer(cwd, run, question);
127
147
  run.gate = inspectGate(cwd, run);
128
148
  writeActiveRun(cwd, run);
129
149
  return question;
130
150
  }
131
151
 
152
+ export function reviseFact(
153
+ cwd: string,
154
+ run: ActiveRun,
155
+ input: { factLabel: string; value: string; reason?: string },
156
+ ): KdFact | undefined {
157
+ const label = input.factLabel.trim();
158
+ const value = input.value.trim();
159
+ if (!label || !value) return undefined;
160
+ const canonicalLabel = canonicalFactLabel(label);
161
+ if (!canonicalLabel) return undefined;
162
+ if (invalidFactValueReason(value)) return undefined;
163
+ const now = new Date().toISOString();
164
+ const fact: KdFact = {
165
+ key: factKey(canonicalLabel),
166
+ label: canonicalLabel,
167
+ value,
168
+ status: "current",
169
+ source: "manual",
170
+ reason: input.reason?.trim() || undefined,
171
+ createdAt: now,
172
+ updatedAt: now,
173
+ };
174
+ run.facts = upsertFact(run.facts, fact);
175
+ run.gate = inspectGate(cwd, run);
176
+ writeActiveRun(cwd, run);
177
+ return fact;
178
+ }
179
+
132
180
  export function openBlockingQuestions(run: ActiveRun): KdQuestion[] {
133
181
  return (Array.isArray(run.questions) ? run.questions : []).filter((question) => question.status === "open" && question.blocking);
134
182
  }
@@ -217,6 +265,12 @@ function writeRunState(cwd: string, run: ActiveRun): void {
217
265
  writeFileSync(runStatePath(cwd, run), `${JSON.stringify(run, null, 2)}\n`, "utf8");
218
266
  }
219
267
 
268
+ function applyQuestionFact(run: ActiveRun, question: KdQuestion): void {
269
+ const fact = factFromAnsweredQuestion(question, question.answeredAt);
270
+ if (!fact) return;
271
+ run.facts = upsertFact(run.facts, fact);
272
+ }
273
+
220
274
  function applyRepairQuestionAnswer(cwd: string, run: ActiveRun, question: KdQuestion): void {
221
275
  if (!isRepairLimitQuestion(question)) return;
222
276
  const answer = question.answer?.trim() ?? "";
@@ -280,13 +334,13 @@ function hydrateRun(parsed: ActiveRun): ActiveRun | undefined {
280
334
  parsed.id = parsed.id.trim();
281
335
  parsed.artifacts = isPlainObject(parsed.artifacts) ? sanitizeArtifacts(parsed.artifacts) : {};
282
336
  parsed.questions = Array.isArray(parsed.questions) ? parsed.questions.map(sanitizeQuestion).filter((question): question is KdQuestion => Boolean(question)) : [];
337
+ parsed.facts = Array.isArray(parsed.facts) ? parsed.facts.map(sanitizeFact).filter((fact): fact is KdFact => Boolean(fact)) : [];
283
338
  parsed.status = parsed.status === "paused" || parsed.status === "done" ? parsed.status : "active";
284
339
  parsed.goal = typeof parsed.goal === "string" ? parsed.goal : undefined;
285
340
  parsed.version = typeof parsed.version === "string" ? parsed.version : undefined;
286
341
  parsed.createdAt = typeof parsed.createdAt === "string" ? parsed.createdAt : typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
287
342
  parsed.updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : parsed.createdAt;
288
- const legacyEdition = (parsed as ActiveRun & { edition?: string }).edition;
289
- const product = typeof parsed.profile?.product === "string" ? parsed.profile.product : typeof parsed.product === "string" ? parsed.product : resolveProductProfile(typeof legacyEdition === "string" ? legacyEdition : undefined).product;
343
+ const product = typeof parsed.profile?.product === "string" ? parsed.profile.product : typeof parsed.product === "string" ? parsed.product : resolveProductProfile(undefined).product;
290
344
  parsed.profile = profileForProduct(product);
291
345
  parsed.product = parsed.profile.product;
292
346
  parsed.riskAssessment = sanitizeRiskAssessment(parsed.riskAssessment);
@@ -305,11 +359,15 @@ function sanitizeQuestion(value: unknown): KdQuestion | undefined {
305
359
  const question = typeof value.question === "string" ? value.question.trim() : "";
306
360
  if (!phase || !question) return undefined;
307
361
  const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : createQuestionId(1);
362
+ const rawFactLabel = typeof value.factLabel === "string" && value.factLabel.trim() ? value.factLabel.trim() : undefined;
363
+ const factLabel = rawFactLabel ? canonicalFactLabel(rawFactLabel) : undefined;
308
364
  return {
309
365
  id,
310
366
  phase,
311
367
  question,
312
368
  reason: typeof value.reason === "string" && value.reason.trim() ? value.reason.trim() : undefined,
369
+ factLabel,
370
+ proposedFactValue: typeof value.proposedFactValue === "string" && value.proposedFactValue.trim() ? value.proposedFactValue.trim() : undefined,
313
371
  choices: Array.isArray(value.choices) ? value.choices.filter((choice): choice is string => typeof choice === "string" && Boolean(choice.trim())).map((choice) => choice.trim()) : undefined,
314
372
  blocking: value.blocking !== false,
315
373
  status: value.status === "answered" ? "answered" : "open",
@@ -319,6 +377,31 @@ function sanitizeQuestion(value: unknown): KdQuestion | undefined {
319
377
  };
320
378
  }
321
379
 
380
+ function sanitizeFact(value: unknown): KdFact | undefined {
381
+ if (!isPlainObject(value)) return undefined;
382
+ const label = typeof value.label === "string" ? value.label.trim() : "";
383
+ const factValue = typeof value.value === "string" ? value.value.trim() : "";
384
+ if (!label || !factValue) return undefined;
385
+ const canonicalLabel = canonicalFactLabel(label);
386
+ if (!canonicalLabel) return undefined;
387
+ if (invalidFactValueReason(factValue)) return undefined;
388
+ const status = value.status === "superseded" || value.status === "rejected" ? value.status : "current";
389
+ const source = value.source === "manual" ? "manual" : "question";
390
+ const now = new Date().toISOString();
391
+ return {
392
+ key: factKey(canonicalLabel),
393
+ label: canonicalLabel,
394
+ value: factValue,
395
+ status,
396
+ source,
397
+ sourceQuestionId: typeof value.sourceQuestionId === "string" && value.sourceQuestionId.trim() ? value.sourceQuestionId.trim() : undefined,
398
+ reason: typeof value.reason === "string" && value.reason.trim() ? value.reason.trim() : undefined,
399
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
400
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
401
+ supersededBy: typeof value.supersededBy === "string" && value.supersededBy.trim() ? value.supersededBy.trim() : undefined,
402
+ };
403
+ }
404
+
322
405
  function sanitizeRiskAssessment(value: unknown): ActiveRun["riskAssessment"] {
323
406
  if (!isPlainObject(value)) return undefined;
324
407
  const level = value.level;
@@ -371,3 +454,7 @@ function createRunId(goal: string): string {
371
454
  function createQuestionId(sequence: number): string {
372
455
  return `Q-${String(sequence).padStart(3, "0")}`;
373
456
  }
457
+
458
+ function normalizeQuestion(question: string): string {
459
+ return question.trim().replace(/\s+/g, "").replace(/[??。;;,,]/g, "");
460
+ }
@@ -32,6 +32,8 @@ export interface KdQuestion {
32
32
  phase: KdPhase;
33
33
  question: string;
34
34
  reason?: string;
35
+ factLabel?: string;
36
+ proposedFactValue?: string;
35
37
  choices?: string[];
36
38
  blocking: boolean;
37
39
  status: "open" | "answered";
@@ -40,6 +42,22 @@ export interface KdQuestion {
40
42
  answeredAt?: string;
41
43
  }
42
44
 
45
+ export type KdFactStatus = "current" | "superseded" | "rejected";
46
+ export type KdFactSource = "question" | "manual";
47
+
48
+ export interface KdFact {
49
+ key: string;
50
+ label: string;
51
+ value: string;
52
+ status: KdFactStatus;
53
+ source: KdFactSource;
54
+ sourceQuestionId?: string;
55
+ reason?: string;
56
+ createdAt: string;
57
+ updatedAt: string;
58
+ supersededBy?: string;
59
+ }
60
+
43
61
  export interface ActiveRun {
44
62
  id: string;
45
63
  goal?: string;
@@ -56,6 +74,7 @@ export interface ActiveRun {
56
74
  artifacts: Record<string, string>;
57
75
  gate: GateResult;
58
76
  questions?: KdQuestion[];
77
+ facts?: KdFact[];
59
78
  }
60
79
 
61
80
  export const PHASE_ORDER: KdPhase[] = ["discuss", "spec", "plan", "execute", "verify", "ship"];
@@ -379,7 +379,7 @@ async function fetchMetadata(config: Record<string, unknown>, target: string): P
379
379
  const route = objectValue(config.route);
380
380
  const routeUrl = routeUrlFromConfig(route);
381
381
  if (!routeUrl) {
382
- throw new Error("未配置表单元数据查询 API。请在 ok-cosmic.json 的 route.apiUrl 中配置统一路由,或设置 COSMIC_ROUTE_API。");
382
+ throw new Error("未配置表单元数据查询 API。必须在 ok-cosmic.json 的 route.apiUrl 中配置统一路由,或设置 COSMIC_ROUTE_API。");
383
383
  }
384
384
 
385
385
  const hasCjk = /[\u3400-\u9fff]/.test(target);
@@ -524,7 +524,7 @@ function runCosmicApi(cwd: string, params: { mode: "search" | "search-method" |
524
524
  const results = searchKnowledge(query, { scopes: ["cosmic", "cangqiong", "xinghan", "flagship"], topK: params.compact ? 5 : 10, minScore: 1 }, knowledgePath);
525
525
  const header = [
526
526
  `KCode Node Cosmic API query (${params.mode})`,
527
- "说明: 当前 npm 包不再调用 Python/SQLite 脚本;这里查询随包金蝶知识库。需要精确方法签名时,请优先结合项目 SDK/编译输出做红绿验证。",
527
+ "说明: 当前 npm 包不再调用 Python/SQLite 脚本;这里查询随包金蝶知识库。精确方法签名必须结合项目 SDK/编译输出做红绿验证。",
528
528
  "",
529
529
  ].join("\n");
530
530
  const stdout = `${header}${formatSearchResults(query, results, knowledgePath)}\n`;
@@ -690,13 +690,13 @@ function lintStatement(stmt: KsqlStatement): KsqlFinding[] {
690
690
  findings.push({ severity: "WARN", line: lineOfOffset(stmt.text, stmt.line, match.index ?? 0), message: "SQL 可读性偏好:成员关系/半连接默认使用 IN,只有 IN 改变语义时才保留 EXISTS 并说明原因。" });
691
691
  }
692
692
  if (/\bUPDATE\b.+\bJOIN\b/i.test(compact)) {
693
- findings.push({ severity: "WARN", line: stmt.line, message: "PostgreSQL 多表更新优先使用 UPDATE ... FROM ... WHERE ...,不要使用 MySQL 风格 UPDATE ... JOIN。" });
693
+ findings.push({ severity: "WARN", line: stmt.line, message: "PostgreSQL 多表更新默认使用 UPDATE ... FROM ... WHERE ...,禁止使用 MySQL 风格 UPDATE ... JOIN。" });
694
694
  }
695
695
  if (/=\s*NULL\b|\bNULL\s*=/i.test(stmt.text)) {
696
696
  findings.push({ severity: "ERROR", line: stmt.line, message: "NULL 判断必须使用 IS NULL / IS NOT NULL,不能使用 = NULL。" });
697
697
  }
698
698
  if (/<>|!=/.test(stmt.text) && hasToken(stmt.text, "NULL")) {
699
- findings.push({ severity: "WARN", line: stmt.line, message: "涉及 NULL 的不等比较需确认语义;PostgreSQL 可优先使用 IS DISTINCT FROM。" });
699
+ findings.push({ severity: "WARN", line: stmt.line, message: "涉及 NULL 的不等比较必须确认语义;PostgreSQL 默认使用 IS DISTINCT FROM。" });
700
700
  }
701
701
  return findings;
702
702
  }
@@ -24,7 +24,7 @@ const PROFILES: Record<KdProduct, ProductProfile> = {
24
24
  language: "unknown",
25
25
  knowledgeScope: "common",
26
26
  requiresMetadataVerification: true,
27
- notes: ["尚未选择产品;不要假设插件技术栈、BOS/Cosmic 平台规则或 KSQL/SQL 交付规则。"],
27
+ notes: ["尚未选择产品;禁止假设插件技术栈、BOS/Cosmic 平台规则或 KSQL/SQL 交付规则。"],
28
28
  },
29
29
  flagship: {
30
30
  product: "flagship",
@@ -36,7 +36,7 @@ const PROFILES: Record<KdProduct, ProductProfile> = {
36
36
  requiresMetadataVerification: true,
37
37
  notes: [
38
38
  "星空旗舰版基于苍穹/Cosmic 平台。使用平台元数据、插件生命周期、SDK 和后置检查约束,但接口可能存在旗舰版差异。",
39
- "如果当前工作区存在 code/ 目录,产品代码应放在 code/ 下。",
39
+ "当前工作区存在 code/ 目录时,产品代码必须放在 code/ 下。",
40
40
  "创建或编辑代码前,必须检查 code/ 下真实项目结构,并跟随其实际布局;可能按云、按应用组织,也可能不分模块。",
41
41
  ],
42
42
  },