kcode-pi 0.1.35 → 0.1.39

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.
@@ -11,8 +11,10 @@ import {
11
11
  ensurePhaseArtifact,
12
12
  finishActiveRun,
13
13
  listRuns,
14
+ openBlockingQuestions,
14
15
  readActiveRun,
15
16
  refreshGate,
17
+ reviseFact,
16
18
  switchActiveRun,
17
19
  updateProductProfile,
18
20
  updatePhaseArtifact,
@@ -28,6 +30,7 @@ import { windowsPathHint } from "../src/platform/path.ts";
28
30
  import { repairPromptForRun, workflowPromptForRun } from "../src/harness/prompt.ts";
29
31
  import { recordVerifyResult, type VerifyResultOutcome } from "../src/harness/repair.ts";
30
32
  import { isSubagentChild, subagentRoleFromEnv, subagentToolCallBlockReason } from "../src/harness/delegation.ts";
33
+ import { questionAskBlockReason } from "../src/harness/question-memory.ts";
31
34
 
32
35
  function requireRun(cwd: string): ReturnType<typeof readActiveRun> {
33
36
  return readActiveRun(cwd);
@@ -172,13 +175,15 @@ const kdQuestionTool = defineTool({
172
175
  name: "kd_question",
173
176
  label: "KD 问题",
174
177
  description:
175
- "创建、回答或列出金蝶 Harness 结构化问题。每次必须只记录一个最阻塞的短问题。",
178
+ "创建、回答、修订或列出金蝶 Harness 结构化问题。每次必须只记录一个最阻塞的短问题。",
176
179
  parameters: Type.Object({
177
- action: Type.Optional(Type.String({ description: "操作类型:ask、answer 或 list,默认 ask。" })),
180
+ action: Type.Optional(Type.String({ description: "操作类型:ask、answer、revise 或 list,默认 ask。" })),
178
181
  id: Type.Optional(Type.String({ description: "回答问题时的问题编号,例如 Q-001。" })),
179
182
  question: Type.Optional(Type.String({ description: "提问内容;必须是一个短问题。" })),
180
183
  answer: Type.Optional(Type.String({ description: "用户答案,action=answer 时使用。" })),
181
184
  reason: Type.Optional(Type.String({ description: "当前阶段阻塞原因。" })),
185
+ factLabel: Type.Optional(Type.String({ description: "该问题要补齐的结构化事实标签,例如 目标 FormId/单据或表单标识。" })),
186
+ proposedFactValue: Type.Optional(Type.String({ description: "确认题中的候选事实值;用户回答是/yes 时写入 factLabel。" })),
182
187
  choices: Type.Optional(Type.Array(Type.String(), { description: "候选答案;最多 3 个简短选项。" })),
183
188
  blocking: Type.Optional(Type.Boolean({ description: "控制问题是否阻塞阶段推进,默认 true。" })),
184
189
  }),
@@ -199,17 +204,24 @@ const kdQuestionTool = defineTool({
199
204
  }
200
205
 
201
206
  if (action === "answer") {
202
- if (!params.id || !params.answer) {
207
+ if (!params.id || !params.answer?.trim()) {
203
208
  return {
204
209
  content: [{ type: "text", text: "kd_question action=answer 必须同时提供 id 和 answer。" }],
205
210
  details: { error: "missing-answer-params" },
206
211
  };
207
212
  }
213
+ const existingQuestion = (run.questions ?? []).find((question) => question.id === params.id);
214
+ if (existingQuestion?.status === "answered") {
215
+ return {
216
+ content: [{ type: "text", text: `${params.id} 已回答,禁止重复覆盖。用户明确更正事实时使用 kd_question action=revise factLabel="<事实标签>" answer="<新值>" reason="<更正原因>"。` }],
217
+ details: { error: "question-already-answered", question: existingQuestion },
218
+ };
219
+ }
208
220
  const answered = answerQuestion(ctx.cwd, run, params.id, params.answer);
209
221
  if (!answered) {
210
222
  return {
211
- content: [{ type: "text", text: `未找到问题:${params.id}` }],
212
- details: { error: "question-not-found", id: params.id },
223
+ content: [{ type: "text", text: existingQuestion ? `未能记录 ${params.id} 的答案;答案不能为空或占位内容。` : `未找到问题:${params.id}` }],
224
+ details: { error: existingQuestion ? "invalid-answer" : "question-not-found", id: params.id },
213
225
  };
214
226
  }
215
227
  appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]);
@@ -220,9 +232,35 @@ const kdQuestionTool = defineTool({
220
232
  };
221
233
  }
222
234
 
235
+ if (action === "revise") {
236
+ if (!params.factLabel?.trim() || !params.answer?.trim()) {
237
+ return {
238
+ content: [{ type: "text", text: "kd_question action=revise 必须同时提供 factLabel 和 answer。" }],
239
+ details: { error: "missing-revise-params" },
240
+ };
241
+ }
242
+ const fact = reviseFact(ctx.cwd, run, {
243
+ factLabel: params.factLabel,
244
+ value: params.answer,
245
+ reason: params.reason,
246
+ });
247
+ if (!fact) {
248
+ return {
249
+ content: [{ type: "text", text: "未能修订结构化事实;factLabel 和 answer 不能为空。" }],
250
+ details: { error: "invalid-revision" },
251
+ };
252
+ }
253
+ appendQuestionEventToArtifact(ctx.cwd, run, [`- 已修订事实 ${fact.label}:${fact.value}`]);
254
+ const auto = autoAdvanceTool(ctx.cwd, run);
255
+ return {
256
+ content: [{ type: "text", text: `已修订事实 ${fact.label}:${fact.value}。${auto.advanced ? auto.message : `门禁仍阻塞:${auto.message}`}` }],
257
+ details: { fact, gate: readActiveRun(ctx.cwd)?.gate, autoAdvance: auto },
258
+ };
259
+ }
260
+
223
261
  if (action !== "ask") {
224
262
  return {
225
- content: [{ type: "text", text: `未知 kd_question action:${params.action}。有效值:ask、answer、list。` }],
263
+ content: [{ type: "text", text: `未知 kd_question action:${params.action}。有效值:ask、answer、revise、list。` }],
226
264
  details: { error: "unknown-action", action: params.action },
227
265
  };
228
266
  }
@@ -246,35 +284,39 @@ const kdQuestionTool = defineTool({
246
284
  "登记第一个必须确认的问题,例如:",
247
285
  "kd_question action=ask question=\"采购入库单 Form ID 是否为 pur_receivebill?\" choices=[\"是\", \"不是\"]",
248
286
  "",
249
- "交互模式弹出选择/输入对话并自动记录;非交互模式必须由用户在对话中回答,再用 kd_question action=answer 记录。",
287
+ "问题登记后保持 open;用户在下一条消息中回答,或使用 kd_question action=answer 记录。",
250
288
  ].join("\n"),
251
289
  },
252
290
  ],
253
291
  details: { error: "batched-question" },
254
292
  };
255
293
  }
294
+ const askBlockReason = questionAskBlockReason(run, { factLabel: params.factLabel });
295
+ if (askBlockReason) {
296
+ return {
297
+ content: [{ type: "text", text: askBlockReason }],
298
+ details: { error: "duplicate-fact-question", factLabel: params.factLabel },
299
+ };
300
+ }
256
301
 
302
+ const existingQuestionIds = new Set((run.questions ?? []).map((question) => question.id));
257
303
  const question = addQuestion(ctx.cwd, run, {
258
304
  question: params.question,
259
305
  reason: params.reason,
306
+ factLabel: params.factLabel,
307
+ proposedFactValue: params.proposedFactValue,
260
308
  choices: params.choices,
261
309
  blocking: params.blocking,
262
310
  });
263
- appendQuestionEventToArtifact(ctx.cwd, run, formatQuestionArtifactLines(question));
264
- const interactiveAnswer = await askQuestionInteractively(ctx, question.question, question.choices);
265
- if (interactiveAnswer) {
266
- const answered = answerQuestion(ctx.cwd, run, question.id, interactiveAnswer);
267
- if (answered) {
268
- appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]);
269
- const auto = autoAdvanceTool(ctx.cwd, run);
270
- return {
271
- content: [{ type: "text", text: `用户已回答 ${answered.id}:${answered.answer}。${auto.advanced ? auto.message : `门禁仍阻塞:${auto.message}`}` }],
272
- details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate, answered: true, autoAdvance: auto },
273
- };
274
- }
311
+ if (existingQuestionIds.has(question.id)) {
312
+ return {
313
+ content: [{ type: "text", text: `${question.id} 已是 open 问题,禁止重复登记。等待用户回答,或使用 kd_question action=answer id=${question.id} answer=<答案> 记录。` }],
314
+ details: { question, gate: readActiveRun(ctx.cwd)?.gate, duplicate: true },
315
+ };
275
316
  }
317
+ appendQuestionEventToArtifact(ctx.cwd, run, formatQuestionArtifactLines(question));
276
318
 
277
- const text = `${formatQuestionCard(question)}\n\n未记录交互式答案;该问题保持 open,直至用户回答。`;
319
+ const text = `${formatQuestionCard(question)}\n\n问题已登记为 open。等待用户在下一条消息中回答,或执行 kd_question action=answer id=${question.id} answer=<答案>。`;
278
320
  return { content: [{ type: "text", text }], details: { question, gate: readActiveRun(ctx.cwd)?.gate, answered: false } };
279
321
  },
280
322
  });
@@ -351,6 +393,20 @@ export default function (pi: ExtensionAPI) {
351
393
 
352
394
  if (!run) return { action: "continue" };
353
395
 
396
+ const openQuestions = openBlockingQuestions(run);
397
+ if (openQuestions.length === 1 && shouldAutoRecordQuestionAnswer(event.text)) {
398
+ const answered = answerQuestion(ctx.cwd, run, openQuestions[0].id, event.text);
399
+ const current = readActiveRun(ctx.cwd) ?? run;
400
+ if (answered) {
401
+ appendQuestionEventToArtifact(ctx.cwd, current, [`- 已回答 ${answered.id}:${answered.answer}`]);
402
+ if (ctx.hasUI) ctx.ui.notify(`已记录 ${answered.id} 的答案`, "info");
403
+ return {
404
+ action: "transform",
405
+ text: workflowPromptForRun(ctx.cwd, current, `用户已回答 ${answered.id}:${answered.answer}`),
406
+ };
407
+ }
408
+ }
409
+
354
410
  return {
355
411
  action: "transform",
356
412
  text: workflowPromptForRun(ctx.cwd, run, event.text),
@@ -618,9 +674,14 @@ export default function (pi: ExtensionAPI) {
618
674
  ctx.ui.notify("用法:/kd-answer Q-001 <答案>", "error");
619
675
  return;
620
676
  }
677
+ const existingQuestion = (run.questions ?? []).find((question) => question.id === id);
678
+ if (existingQuestion?.status === "answered") {
679
+ ctx.ui.notify(`${id} 已回答,禁止重复覆盖;用户更正事实时使用 kd_question action=revise。`, "warning");
680
+ return;
681
+ }
621
682
  const answered = answerQuestion(ctx.cwd, run, id, answer);
622
683
  if (!answered) {
623
- ctx.ui.notify(`未找到问题:${id}`, "error");
684
+ ctx.ui.notify(existingQuestion ? `未能记录 ${id} 的答案;答案不能为空、占位内容或单独确认/否定。` : `未找到问题:${id}`, "error");
624
685
  return;
625
686
  }
626
687
  appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]);
@@ -687,38 +748,17 @@ function formatQuestionCard(question: NonNullable<NonNullable<ReturnType<typeof
687
748
  `阶段:${question.phase}`,
688
749
  `问题:${question.question}`,
689
750
  question.reason ? `原因:${question.reason}` : undefined,
751
+ question.factLabel ? `事实标签:${question.factLabel}` : undefined,
752
+ question.proposedFactValue ? `候选事实值:${question.proposedFactValue}` : undefined,
690
753
  question.choices?.length ? `选项:${question.choices.join(" | ")}` : undefined,
691
754
  question.answer ? `答案:${question.answer}` : undefined,
692
755
  question.status === "open"
693
- ? `交互弹窗出现时直接回答;无弹窗时在对话中回复,并使用 kd_question action=answer id=${question.id} answer=<答案> 记录。`
756
+ ? `回答方式:用户在下一条消息中回答,或使用 kd_question action=answer id=${question.id} answer=<答案> 记录。`
694
757
  : undefined,
695
758
  ];
696
759
  return lines.filter(Boolean).join("\n");
697
760
  }
698
761
 
699
- async function askQuestionInteractively(
700
- ctx: ExtensionContext,
701
- question: string,
702
- choices: string[] | undefined,
703
- ): Promise<string | undefined> {
704
- if (!ctx.hasUI) return undefined;
705
-
706
- const normalizedChoices = choices?.map((choice) => choice.trim()).filter(Boolean) ?? [];
707
- try {
708
- if (normalizedChoices.length === 0) {
709
- return (await ctx.ui.input(question, "输入答案"))?.trim() || undefined;
710
- }
711
-
712
- const customChoice = "自定义输入";
713
- const selected = await ctx.ui.select(question, [...normalizedChoices, customChoice]);
714
- if (!selected) return undefined;
715
- if (selected !== customChoice) return selected;
716
- return (await ctx.ui.input(question, "输入答案"))?.trim() || undefined;
717
- } catch {
718
- return undefined;
719
- }
720
- }
721
-
722
762
  function questionBatchProblem(question: string, choices?: string[]): string | undefined {
723
763
  const text = question.trim();
724
764
  const numberedItems = text.split(/\r?\n/).filter((line) => /^\s*(\d+[\.\)、)]|[-*]\s+)/.test(line)).length;
@@ -732,10 +772,22 @@ function questionBatchProblem(question: string, choices?: string[]): string | un
732
772
  return undefined;
733
773
  }
734
774
 
775
+ function shouldAutoRecordQuestionAnswer(text: string): boolean {
776
+ const trimmed = text.trim();
777
+ if (!trimmed || trimmed.startsWith("/")) return false;
778
+ if (trimmed.length > 300) return false;
779
+ if (/[??]/.test(trimmed)) return false;
780
+ if (/^(先|暂停|等等|不用|不要|继续|开始|实现|修改|修复|提交|push|发布)\b/i.test(trimmed)) return false;
781
+ if (/^(先|暂停|等等|不用|不要|继续|开始|实现|修改|修复|提交|发布)/.test(trimmed)) return false;
782
+ return true;
783
+ }
784
+
735
785
  function formatQuestionArtifactLines(question: NonNullable<NonNullable<ReturnType<typeof readActiveRun>>["questions"]>[number]): string[] {
736
786
  return [
737
787
  `- ${question.id} [${question.blocking ? "blocking" : "non-blocking"}] ${question.question}`,
738
788
  question.reason ? ` - 原因:${question.reason}` : undefined,
789
+ question.factLabel ? ` - 事实标签:${question.factLabel}` : undefined,
790
+ question.proposedFactValue ? ` - 候选事实值:${question.proposedFactValue}` : undefined,
739
791
  question.choices?.length ? ` - 选项:${question.choices.join(" | ")}` : undefined,
740
792
  " - 状态:open",
741
793
  ].filter(Boolean) as string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.35",
3
+ "version": "0.1.39",
4
4
  "description": "面向金蝶开发的 Pi Coding Agent 启动器、工具包和 Harness 工作流",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, extname, join, relative } from "node:path";
3
+ import { PROJECT_PERSISTENT_RULES, formatPromptLines } from "../harness/prompt-policy.js";
3
4
 
4
5
  const IGNORED_DIRS = new Set([
5
6
  ".git",
@@ -67,11 +68,9 @@ export function generateProjectContext(cwd: string): string {
67
68
  "",
68
69
  "## 持久规则",
69
70
  "",
70
- "- 本文件是 KCode 的项目记忆,计划或编辑代码前必须读取。",
71
- "- 业务需求不得生成 demo/sample/scaffold 代码。",
71
+ ...formatPromptLines(PROJECT_PERSISTENT_RULES),
72
72
  "- 禁止假设模块结构。必须基于下方真实路径,并在编辑前确认目标文件。",
73
73
  "- 调用文件工具时默认使用项目相对路径。在 Windows 中禁止把路径改写为 /mnt/<drive>/... 或 /<drive>/...;绝对路径只允许使用 Windows 路径。",
74
- "- 本文件过期时,计划前运行 `kcode context --refresh` 重新生成。",
75
74
  "- 只有 Harness 进入 `execute` 且 PLAN.md 写明真实目标路径后,才能写产品代码。",
76
75
  "",
77
76
  "## 项目结构摘要",
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { isAbsolute, join, relative } from "node:path";
3
3
  import type { ActiveRun } from "./types.ts";
4
4
  import { readArtifact } from "./artifacts.ts";
5
- import { hasEvidenceEntry } from "./evidence.ts";
5
+ import { hasEvidenceEntry, readEvidenceIndex } from "./evidence.ts";
6
6
  import { runRoot } from "./paths.ts";
7
7
  import { isSourceLikePath } from "./path-policy.ts";
8
8
  import {
@@ -19,6 +19,7 @@ import {
19
19
  INTEGRATION_CONTEXT_FIELDS,
20
20
  type ContractField,
21
21
  } from "./prompt-policy.ts";
22
+ import { formatAnsweredQuestionFacts } from "./question-memory.ts";
22
23
 
23
24
  export const COSMIC_METADATA_EVIDENCE = "evidence/cosmic-metadata.json";
24
25
  export const DATA_SOURCE_EVIDENCE = "evidence/data-source.md";
@@ -53,8 +54,10 @@ export function hasValidDataSourceEvidence(cwd: string, run: ActiveRun): boolean
53
54
  const path = join(runRoot(cwd, run), evidence);
54
55
  if (!existsSync(path)) return false;
55
56
  if (!hasEvidenceEntry(cwd, run, evidence)) return false;
56
- if (evidence === COSMIC_METADATA_EVIDENCE) return true;
57
- return dataSourceEvidenceContentLooksConcrete(readFileSync(path, "utf8"));
57
+ if (!hasSuccessfulEvidenceEntry(cwd, run, evidence)) return false;
58
+ const content = readFileSync(path, "utf8");
59
+ if (evidence === COSMIC_METADATA_EVIDENCE) return cosmicMetadataLooksConcrete(content);
60
+ return dataSourceEvidenceContentLooksConcrete(content);
58
61
  }
59
62
 
60
63
  export function dataSourceProductionWriteBlockReason(cwd: string, run: ActiveRun | undefined, path: string | undefined): string | undefined {
@@ -89,7 +92,12 @@ export function dataSourceContextBlockReason(cwd: string, run: ActiveRun): strin
89
92
  }
90
93
 
91
94
  function dataSourcePlanningText(cwd: string, run: ActiveRun, options: { includePlan?: boolean } = { includePlan: true }): string {
92
- return [run.goal ?? "", readArtifact(cwd, run, "spec") ?? "", options.includePlan === false ? "" : readArtifact(cwd, run, "plan") ?? ""].join("\n");
95
+ return [
96
+ run.goal ?? "",
97
+ readArtifact(cwd, run, "spec") ?? "",
98
+ formatAnsweredQuestionFacts(run),
99
+ options.includePlan === false ? "" : readArtifact(cwd, run, "plan") ?? "",
100
+ ].join("\n");
93
101
  }
94
102
 
95
103
  function planDeclaresConcreteDataSourceWork(cwd: string, run: ActiveRun): boolean {
@@ -132,7 +140,7 @@ function planDeclaresConcreteImplementationWork(cwd: string, run: ActiveRun): bo
132
140
  }
133
141
 
134
142
  function missingDataSourceContextItems(cwd: string, run: ActiveRun): string[] {
135
- const text = dataSourcePlanningText(cwd, run);
143
+ const text = trustedDataSourceContextText(cwd, run);
136
144
  const missing: string[] = [];
137
145
 
138
146
  if (!hasTargetObjectIdentifier(text)) missing.push(DATA_SOURCE_FIELDS["target-form"].label);
@@ -144,6 +152,17 @@ function missingDataSourceContextItems(cwd: string, run: ActiveRun): string[] {
144
152
  return missing;
145
153
  }
146
154
 
155
+ function trustedDataSourceContextText(cwd: string, run: ActiveRun): string {
156
+ return [formatAnsweredQuestionFacts(run), dataSourceEvidenceContent(cwd, run)].join("\n");
157
+ }
158
+
159
+ function dataSourceEvidenceContent(cwd: string, run: ActiveRun): string {
160
+ const evidence = dataSourceEvidenceForRun(run);
161
+ if (!evidence) return "";
162
+ const path = join(runRoot(cwd, run), evidence);
163
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
164
+ }
165
+
147
166
  function hasTriggerOrEntry(text: string): boolean {
148
167
  if (hasLabeledValue(text, IMPLEMENTATION_FIELDS.trigger)) return true;
149
168
  return /(?:触发入口|执行时机|触发时机|入口|事件|生命周期|插件类型|按钮|操作|定时|任务|保存前|保存后|提交后|审核后|Before\w+|After\w+)\s*[::=]\s*[^。\n\r,,;;]{2,}|(?:保存前|保存后|提交后|审核后|定时|按钮点击|操作执行)/i.test(text);
@@ -251,7 +270,7 @@ function hasAcceptanceSample(text: string): boolean {
251
270
 
252
271
  function hasTargetObjectIdentifier(text: string): boolean {
253
272
  if (hasLabeledValue(text, DATA_SOURCE_FIELDS["target-form"])) return true;
254
- return /(?:Form\s*ID|FormId|formid|表单标识|单据标识|目标单据|目标表单)\s*[::=]\s*[A-Za-z0-9_.-]{2,}|(?:单据|表单)\s*[::=]\s*[^。\n\r,,;;]{2,}/i.test(text);
273
+ return /["']?(?:Form\s*ID|FormId|formId|formid|表单标识|单据标识|目标单据|目标表单)["']?\s*[::=]\s*["']?[A-Za-z0-9_.-]{2,}|(?:单据|表单)\s*[::=]\s*[^。\n\r,,;;]{2,}/i.test(text);
255
274
  }
256
275
 
257
276
  function hasPluginHook(text: string): boolean {
@@ -286,17 +305,42 @@ function dataSourceEvidenceContentLooksConcrete(content: string): boolean {
286
305
  return /Form\s*ID|FormId|formid|单据|表单|字段|实体|表名|数据源|BOS|DynamicObject|DataSet|数据库|测试数据/i.test(text);
287
306
  }
288
307
 
308
+ function cosmicMetadataLooksConcrete(content: string): boolean {
309
+ try {
310
+ const parsed = JSON.parse(content) as unknown;
311
+ if (!parsed || (Array.isArray(parsed) && parsed.length === 0)) return false;
312
+ const text = JSON.stringify(parsed);
313
+ if (/待确认|未知|TODO|TBD|未提供/i.test(text)) return false;
314
+ return /Form\s*ID|FormId|formid|field|fields|entity|entities|字段|实体|单据体|name|key|id/i.test(text);
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+
320
+ function hasSuccessfulEvidenceEntry(cwd: string, run: ActiveRun, artifact: string): boolean {
321
+ const normalized = artifact.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
322
+ const entry = readEvidenceIndex(cwd, run).entries.find((item) => typeof item.path === "string" && item.path.replace(/\\/g, "/") === normalized);
323
+ return Boolean(entry && entry.exitCode === 0);
324
+ }
325
+
289
326
  function normalizeRelativePath(path: string): string {
290
327
  return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
291
328
  }
292
329
 
293
330
  function hasLabeledValue(text: string, field: ContractField): boolean {
294
- return field.aliases.some((alias) => {
331
+ return [field.label, ...field.aliases].some((alias) => {
295
332
  const escaped = escapeRegExp(alias);
296
- return new RegExp(`(?:^|[\\r\\n\\-\\*\\s])${escaped}\\s*[::=]\\s*[^\\r\\n。;;,,]{2,}`, "i").test(text);
333
+ const match = new RegExp(`(?:^|[\\r\\n\\-\\*\\s])${escaped}\\s*[::=]\\s*([^\\r\\n。;;,,]{2,})`, "i").exec(text);
334
+ return Boolean(match && labeledValueLooksConcrete(match[1] ?? ""));
297
335
  });
298
336
  }
299
337
 
338
+ function labeledValueLooksConcrete(value: string): boolean {
339
+ const trimmed = value.trim();
340
+ if (trimmed.length < 2) return false;
341
+ return !/^(待确认|未知|不清楚|未提供|none|unknown|todo|tbd|n\/a|按实际|按实际环境|后续确认|以后再说)$/i.test(trimmed);
342
+ }
343
+
300
344
  function escapeRegExp(value: string): string {
301
345
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
346
  }
@@ -4,6 +4,7 @@ import { formatStatus } from "./format.ts";
4
4
  import type { ActiveRun, KdPhase } from "./types.ts";
5
5
  import { PHASE_ORDER } from "./types.ts";
6
6
  import { isAbsolute, relative } from "node:path";
7
+ import { formatQuestionMemory } from "./question-memory.ts";
7
8
 
8
9
  export type DelegationRole = "research" | "doc" | "code" | "review" | "verify";
9
10
 
@@ -21,6 +22,7 @@ export interface ParsedDelegationArgs extends DelegationRequest {
21
22
  const ROLE_GUIDANCE: Record<DelegationRole, string[]> = {
22
23
  research: [
23
24
  "只读调研项目、文档、SDK 线索和现有实现。",
25
+ "必须基于真实读取或搜索结果定位文件;禁止猜测路径。",
24
26
  "输出压缩结论、证据文件/代码位置、风险和下一步指令。",
25
27
  "禁止修改文件;禁止推进 Harness 状态。",
26
28
  ],
@@ -179,6 +181,7 @@ export function delegationGuidanceForWorkflow(): string {
179
181
  "- 任务包含大量调研、独立交叉审查、长上下文复盘或可并行拆分内容时,使用 kd_subagent。",
180
182
  "- 自动委派只做旁路工作;主 agent 仍负责采纳结论、修改文件、记录 evidence 和推进阶段。",
181
183
  "- code 委派只能在 execute 阶段且限于 PLAN.md 批准文件;review/research 默认只读。",
184
+ "- kd_subagent 不是 shell/read/grep 失败的替代方案;基础文件搜索失败时,主 agent 必须报告阻塞原因或改用当前环境可用工具。",
182
185
  ].join("\n");
183
186
  }
184
187
 
@@ -202,6 +205,7 @@ export function buildDelegationPrompt(cwd: string, run: ActiveRun | undefined, r
202
205
  const projectContext = readProjectContext(cwd);
203
206
  const status = run ? formatStatus(cwd, run) : "当前没有 active KCode Harness run。";
204
207
  const phaseContext = run ? delegationMemoryForRun(cwd, run) : "无 active run 阶段资料。";
208
+ const questionMemory = run ? formatQuestionMemory(run) : "无。";
205
209
 
206
210
  return [
207
211
  "KCode 子 agent 委派任务。",
@@ -219,6 +223,7 @@ export function buildDelegationPrompt(cwd: string, run: ActiveRun | undefined, r
219
223
  ...roleGuidance.map((item) => `- ${item}`),
220
224
  "- 子 agent 不是主状态机;禁止调用 /kd-advance、/kd-finish 或改变 run 生命周期。",
221
225
  "- 禁止创建子 agent;输出结果交回主 agent 统一决策。",
226
+ "- 禁止假设 bash、Linux 路径或猜测文件位置;Windows 项目按 PowerShell/Windows 路径语义描述命令。",
222
227
  "",
223
228
  "Harness 状态:",
224
229
  status,
@@ -226,6 +231,9 @@ export function buildDelegationPrompt(cwd: string, run: ActiveRun | undefined, r
226
231
  "阶段上下文:",
227
232
  phaseContext,
228
233
  "",
234
+ "KCode 已问已答事实:",
235
+ questionMemory,
236
+ "",
229
237
  "项目上下文:",
230
238
  projectContext ? trimForPrompt(projectContext, 1200) : "未生成。项目结构缺失时读取本地文件;无法读取时要求主 agent 运行 `kcode context --refresh`。",
231
239
  "",
@@ -168,5 +168,5 @@ export function implementationWriteBlockedReason(path: string, missing: string[]
168
168
  function nextQuestionInstruction(missing: string[]): string | undefined {
169
169
  const question = questionForMissingLabel(missing[0]);
170
170
  if (!question) return undefined;
171
- return `阻断问题命令:kd_question action=ask question="${question}" reason="${missing[0]} 缺失会阻塞实现。"。`;
171
+ return `阻断问题命令:kd_question action=ask factLabel="${missing[0]}" question="${question}" reason="${missing[0]} 缺失会阻塞实现。"。`;
172
172
  }
@@ -56,7 +56,7 @@ export const DATA_SOURCE_CONTEXT_FIELDS: ContractField[] = [
56
56
  {
57
57
  id: "target-form",
58
58
  label: "目标 FormId/单据或表单标识",
59
- aliases: ["目标 FormId", "FormId", "表单标识", "单据标识", "目标单据", "目标表单"],
59
+ aliases: ["目标 FormId", "FormId", "Form ID", "form id", "表单标识", "单据标识", "目标单据", "目标表单"],
60
60
  question: "目标 FormId、单据或表单标识是什么?",
61
61
  },
62
62
  {
@@ -142,6 +142,25 @@ export const INTEGRATION_CONTEXT_FIELDS: ContractField[] = [
142
142
  },
143
143
  ];
144
144
 
145
+ export const PROJECT_PERSISTENT_RULES = [
146
+ "计划或编辑代码前必须读取本文件;本文件过期时先运行 `kcode context --refresh`。",
147
+ "信息不足时禁止开始编码。必须先登记一个最阻塞的结构化问题,获得可核验答案后再继续;禁止输出 demo/sample/scaffold、模板代码或占位实现。",
148
+ "API 文档、SDK 文档和知识库只能证明技术用法,不能替代业务事实。FormId、单据/表单标识、字段/实体/分录标识、插件类型与事件、SQL/KSQL 表名和数据库字段名必须来自用户确认、项目元数据或 evidence。",
149
+ `产品代码实现前必须具备通用实现契约:${IMPLEMENTATION_CONTRACT_FIELDS.map((field) => field.label).join("、")}。`,
150
+ `涉及业务数据源时必须具备数据源上下文:${DATA_SOURCE_CONTEXT_FIELDS.map((field) => field.label).join("、")}。`,
151
+ `涉及第三方对接时必须具备接口与运行上下文:${INTEGRATION_CONTEXT_FIELDS.map((field) => field.label).join("、")}。`,
152
+ "内部插件、自动下推、字段改写、数据同步等需求不得按场景写死提示词;统一通过实现契约、数据源上下文、第三方对接上下文补齐事实。",
153
+ "PLAN 自由文本不能单独证明 FormId、字段、插件事件或读写方式;门禁只信任结构化 facts 和 evidence。",
154
+ "`run.facts` 是唯一结构化事实源;已回答 questions 仅为审计记录,禁止在读取状态时从历史 question 反推事实。",
155
+ "`factLabel` 必须使用集中定义的事实标签或别名;未知标签、占位答案、口头确认语、待确认/TODO/TBD/按实际环境等不能解除门禁。",
156
+ "用户回答 open question 后,先用 `kd_question action=answer` 写入答案;用户更正事实时用 `kd_question action=revise`,禁止重复询问已确认事实。",
157
+ "企业版 Python 插件通常没有本地构建可替代验证;BOS 注册、外部系统操作、人工功能测试和生产环境验证必须由用户提供可核验证据并记录来源。",
158
+ "提示语集中管理为正式工程指令;禁止口语化、闲聊式、鼓励式提示词进入运行时规则。",
159
+ "工具使用必须匹配当前会话实际可用工具和操作系统;Windows 默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`,禁止假设 bash 可用。",
160
+ "文件定位必须使用真实搜索或目录读取结果;工具不可用时明确说明阻塞原因和需要用户执行的命令,禁止猜测路径、反复自述工具失败或用 kd_subagent 代替基础文件搜索。",
161
+ "不做旧状态迁移兼容或旧问题答案推断;坏状态只过滤,缺失事实必须重新提问确认。",
162
+ ];
163
+
145
164
  export const PROMPT_STYLE_RULES = [
146
165
  "使用正式、可执行的工程指令;禁止口语化、闲聊式、鼓励式表达。",
147
166
  "事实不足时生成阻断问题;禁止输出模板代码、占位实现或基于猜测的业务标识。",
@@ -155,11 +174,16 @@ export const CORE_WORKFLOW_CONSTRAINTS = [
155
174
  "业务数据源未知时禁止编码;确认目标 FormId/单据或表单、插件类型和事件、字段/实体/分录标识、数据读取写入方式后再编码;SQL/KSQL 同步确认表名和数据库字段名。",
156
175
  "第三方对接确认接口文档、对接方向、触发时机、认证配置、字段映射、并发/幂等、重试超时限流、错误补偿、日志脱敏和验收样例后再编码。",
157
176
  "事实缺失时使用 kd_question 登记一个最阻塞问题;禁止用 API 文档、SDK 知识库或推测替代业务事实。",
177
+ "用户输入是在回答 open question 时,必须先调用 kd_question action=answer 记录答案,再继续推进或登记下一个问题。",
178
+ "同一 factLabel 已有当前事实时禁止重复提问;用户明确更正时使用 kd_question action=revise 记录新事实和更正原因。",
179
+ "run.facts 是唯一结构化事实源;questions 仅作为问答审计记录,禁止从历史 question 反推门禁事实。",
180
+ "待确认、未知、按实际环境、TODO/TBD 等占位答案不能解除门禁。",
158
181
  "Java/C# SDK 签名以当前项目 jar/dll、构建输出或官方元数据为准。",
159
182
  "Java/Cosmic 使用当前项目 Gradle;C#/企业版使用 dotnet build。",
160
183
  "evidence 必须记录命令、Exit 和关键输出;命令无法运行时记录阻塞原因。",
161
184
  "外部系统操作、BOS 注册、人工功能测试和生产环境验证不能由 LLM 代办;必须要求用户提供验证结果或可核验证据,并记录证据来源。",
162
185
  "Windows 路径规则:项目相对路径为默认;绝对路径使用 D:\\... 形式。",
186
+ "工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,可用 PowerShell/rg/Get-ChildItem/Get-Content;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
163
187
  ];
164
188
 
165
189
  export const PHASE_GUIDANCE: Record<KdPhase, string> = {
@@ -194,3 +218,13 @@ export function questionForMissingLabel(label: string): string | undefined {
194
218
  const fields = [...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS];
195
219
  return fields.find((field) => field.label === label)?.question;
196
220
  }
221
+
222
+ export function canonicalFactLabel(label: string): string | undefined {
223
+ const normalized = normalizeLabel(label);
224
+ const fields = [...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS];
225
+ return fields.find((field) => [field.label, ...field.aliases].some((item) => normalizeLabel(item) === normalized))?.label;
226
+ }
227
+
228
+ function normalizeLabel(label: string): string {
229
+ return label.trim().toLowerCase().replace(/\s+/g, "");
230
+ }
@@ -5,6 +5,7 @@ import { formatStatus } from "./format.ts";
5
5
  import type { ActiveRun, KdPhase } from "./types.ts";
6
6
  import { PHASE_ORDER } from "./types.ts";
7
7
  import { CORE_WORKFLOW_CONSTRAINTS, PHASE_GUIDANCE, PROMPT_STYLE_RULES, formatPromptLines } from "./prompt-policy.ts";
8
+ import { formatQuestionMemory } from "./question-memory.ts";
8
9
 
9
10
  export function workflowPromptForRun(cwd: string, run: ActiveRun, userText: string): string {
10
11
  const status = formatStatus(cwd, run);
@@ -23,6 +24,9 @@ export function workflowPromptForRun(cwd: string, run: ActiveRun, userText: stri
23
24
  "KCode 阶段资料:",
24
25
  memory,
25
26
  "",
27
+ "KCode 已问已答事实:",
28
+ formatQuestionMemory(run),
29
+ "",
26
30
  "项目上下文:",
27
31
  projectContext ? trimForPrompt(projectContext, 1200) : "未生成。项目结构缺失时运行 `kcode context --refresh`。",
28
32
  "",