kcode-pi 0.1.39 → 0.1.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.
package/dist/cli/kcode.js CHANGED
@@ -73,6 +73,7 @@ export function doctor(cwd, args = []) {
73
73
  lines.push(`KCode package:${packageRoot}`);
74
74
  lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,运行 kcode init"}`);
75
75
  lines.push(`项目上下文:${existsSync(projectContextPath) ? projectContextPath : "未创建,运行 kcode context"}`);
76
+ lines.push(`Windows bash:${process.platform === "win32" ? formatWindowsBashStatus(readSettingsSafe(settingsPath)) : "不适用"}`);
76
77
  if (existsSync(settingsPath)) {
77
78
  const settingsResult = readSettingsSafe(settingsPath);
78
79
  if (!settingsResult.ok) {
@@ -109,6 +110,10 @@ export function doctor(cwd, args = []) {
109
110
  warnings++;
110
111
  lines.push("[WARN] 项目上下文不存在,运行 kcode context --refresh 或 kcode repair。");
111
112
  }
113
+ if (process.platform === "win32" && !findWindowsBash(readSettingsSafe(settingsPath))) {
114
+ warnings++;
115
+ lines.push("[WARN] Pi 内置 bash 在当前 Windows 环境不可用;KCode 已覆盖 bash 为 PowerShell,并提供 kd_find_file/kd_list_dir。安装 Git for Windows 可恢复 Pi 原生 bash。");
116
+ }
112
117
  const npmPrefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
113
118
  const npmRoot = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
114
119
  lines.push(`npm prefix -g:${npmPrefix.status === 0 ? npmPrefix.stdout.trim() : "不可用"}`);
@@ -305,3 +310,22 @@ function helpText() {
305
310
  " kcode start 初始化项目配置后启动 KCode 工作环境",
306
311
  ].join("\n");
307
312
  }
313
+ function formatWindowsBashStatus(settingsResult) {
314
+ const bash = findWindowsBash(settingsResult);
315
+ return bash ? `可用(${bash})` : "不可用;KCode 将使用 PowerShell 覆盖工具和 kd_find_file/kd_list_dir";
316
+ }
317
+ function findWindowsBash(settingsResult) {
318
+ const configured = settingsResult?.ok && typeof settingsResult.settings.shellPath === "string" ? settingsResult.settings.shellPath : undefined;
319
+ if (configured && existsSync(configured))
320
+ return configured;
321
+ const candidates = [
322
+ process.env.ProgramFiles ? join(process.env.ProgramFiles, "Git", "bin", "bash.exe") : undefined,
323
+ process.env["ProgramFiles(x86)"] ? join(process.env["ProgramFiles(x86)"], "Git", "bin", "bash.exe") : undefined,
324
+ ...pathEntries().map((entry) => join(entry, "bash.exe")),
325
+ ].filter((item) => Boolean(item));
326
+ return candidates.find((candidate) => existsSync(candidate));
327
+ }
328
+ function pathEntries() {
329
+ const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
330
+ return (process.env[pathKey] ?? "").split(";").filter(Boolean);
331
+ }
@@ -15,5 +15,7 @@ export declare const PHASE_GUIDANCE: Record<KdPhase, string>;
15
15
  export declare const PLAN_REQUIRED_CHECK_LINES: string[];
16
16
  export declare function formatPromptLines(lines: string[]): string[];
17
17
  export declare function fieldLabels(fields: ContractField[]): string[];
18
+ export declare function allFactLabels(): string[];
19
+ export declare function formatFactLabelCatalog(): string;
18
20
  export declare function questionForMissingLabel(label: string): string | undefined;
19
21
  export declare function canonicalFactLabel(label: string): string | undefined;
@@ -145,6 +145,7 @@ export const PROJECT_PERSISTENT_RULES = [
145
145
  "企业版 Python 插件通常没有本地构建可替代验证;BOS 注册、外部系统操作、人工功能测试和生产环境验证必须由用户提供可核验证据并记录来源。",
146
146
  "提示语集中管理为正式工程指令;禁止口语化、闲聊式、鼓励式提示词进入运行时规则。",
147
147
  "工具使用必须匹配当前会话实际可用工具和操作系统;Windows 默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`,禁止假设 bash 可用。",
148
+ "Windows 查找文件和目录时优先使用 `kd_find_file`、`kd_list_dir` 或 PowerShell;禁止用 Linux find/ls 语法反复调用 Pi 内置 bash。",
148
149
  "文件定位必须使用真实搜索或目录读取结果;工具不可用时明确说明阻塞原因和需要用户执行的命令,禁止猜测路径、反复自述工具失败或用 kd_subagent 代替基础文件搜索。",
149
150
  "不做旧状态迁移兼容或旧问题答案推断;坏状态只过滤,缺失事实必须重新提问确认。",
150
151
  ];
@@ -169,7 +170,7 @@ export const CORE_WORKFLOW_CONSTRAINTS = [
169
170
  "evidence 必须记录命令、Exit 和关键输出;命令无法运行时记录阻塞原因。",
170
171
  "外部系统操作、BOS 注册、人工功能测试和生产环境验证不能由 LLM 代办;必须要求用户提供验证结果或可核验证据,并记录证据来源。",
171
172
  "Windows 路径规则:项目相对路径为默认;绝对路径使用 D:\\... 形式。",
172
- "工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,可用 PowerShell/rg/Get-ChildItem/Get-Content;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
173
+ "工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,优先用 kd_find_file/kd_list_dir/PowerShell;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
173
174
  ];
174
175
  export const PHASE_GUIDANCE = {
175
176
  discuss: "梳理需求来源、范围、已知事实;如缺通用实现契约、数据源或第三方接口关键事实,使用 kd_question 登记一个最阻塞问题。",
@@ -195,6 +196,23 @@ export function formatPromptLines(lines) {
195
196
  export function fieldLabels(fields) {
196
197
  return fields.map((field) => field.label);
197
198
  }
199
+ export function allFactLabels() {
200
+ return fieldLabels([...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS]);
201
+ }
202
+ export function formatFactLabelCatalog() {
203
+ return [
204
+ "## 实现契约事实标签",
205
+ ...formatPromptLines(fieldLabels(IMPLEMENTATION_CONTRACT_FIELDS)),
206
+ "",
207
+ "## 数据源上下文事实标签",
208
+ ...formatPromptLines(fieldLabels(DATA_SOURCE_CONTEXT_FIELDS)),
209
+ "",
210
+ "## 第三方对接事实标签",
211
+ ...formatPromptLines(fieldLabels(INTEGRATION_CONTEXT_FIELDS)),
212
+ "",
213
+ "产品类型/产品画像不是 factLabel;使用 /kd-product <产品> 或先登记无 factLabel 的产品确认问题。",
214
+ ].join("\n");
215
+ }
198
216
  export function questionForMissingLabel(label) {
199
217
  const fields = [...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS];
200
218
  return fields.find((field) => field.label === label)?.question;
package/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@
6
6
 
7
7
  - 暂无。
8
8
 
9
+ ## 0.1.40 - 2026-06-07
10
+
11
+ ### 修复
12
+
13
+ - Windows 下覆盖 Pi 内置 `bash` 工具为 PowerShell 执行,避免未安装 Git Bash 时反复搜索 `C:\Program Files\Git\bin\bash.exe`。
14
+ - 新增 `kd_find_file` 和 `kd_list_dir`,用于不依赖 shell 的文件查找和目录探索。
15
+ - `kcode doctor --deep` 增加 Windows bash 状态诊断,并提示 KCode 的 PowerShell 覆盖和文件工具。
16
+ - 持久规则明确 Windows 文件定位优先使用 `kd_find_file`、`kd_list_dir` 或 PowerShell。
17
+ - `kd_question action=labels` 可列出合法 `factLabel`,未知标签错误会返回完整标签目录,避免 LLM 反复猜标签。
18
+ - `kd_question` 拒绝把产品类型和 FormId/字段/插件事件等多个事实塞进同一个 `factLabel`。
19
+
20
+ ### 验证
21
+
22
+ - `npm run check`
23
+ - `npm run smoke:harness`
24
+ - `npm run smoke:kcode-command`
25
+ - `npm run smoke:package`
26
+ - `npm run smoke:official`
27
+ - `npm run build:cli`
28
+ - `git diff --check`
29
+
9
30
  ## 0.1.39 - 2026-06-07
10
31
 
11
32
  ### 修复
@@ -311,6 +311,7 @@ kd_question action=list
311
311
  ```
312
312
 
313
313
  一次只能登记一个当前最阻塞的问题,最多 3 个简短选项。`action=ask` 只登记 open 问题,不弹出输入框、不在工具调用内继续追问。
314
+ 使用 `action=labels` 查看合法 `factLabel`。产品类型/产品画像不是 `factLabel`,应使用 `/kd-product <产品>` 或先登记无 `factLabel` 的产品确认问题。
314
315
 
315
316
  结构化事实参数:
316
317
 
@@ -133,3 +133,36 @@ ENOENT: no such file or directory, access 'D:\mnt\d\projects\xxx\src\main\java\F
133
133
  ```powershell
134
134
  kcode context --refresh
135
135
  ```
136
+
137
+ ## Windows 下 bash 工具一直搜索 Git Bash
138
+
139
+ 现象:
140
+
141
+ ```text
142
+ Searched Git Bash in:
143
+ C:\Program Files\Git\bin\bash.exe
144
+ C:\Program Files (x86)\Git\bin\bash.exe
145
+ ```
146
+
147
+ 原因是 Pi 内置 `bash` 工具在 Windows 上要求 Git Bash、Cygwin 或 MSYS2。KCode 在 Windows 下会覆盖该工具为 PowerShell,并提供 `kd_find_file`、`kd_list_dir` 用于文件查找和目录探索。
148
+
149
+ 处理:
150
+
151
+ ```powershell
152
+ kcode repair
153
+ kcode doctor --deep
154
+ kcode start
155
+ ```
156
+
157
+ 在 KCode 会话中查找文件时使用:
158
+
159
+ ```text
160
+ kd_find_file name=MesApiClient.cs root=C:\Users\Administrator\Desktop\lf-project
161
+ kd_list_dir path=C:\Users\Administrator\Desktop\lf-project
162
+ ```
163
+
164
+ 如果需要 Pi 原生 bash 行为,安装 Git for Windows 后重新运行:
165
+
166
+ ```powershell
167
+ kcode doctor --deep
168
+ ```
@@ -30,7 +30,8 @@ import { windowsPathHint } from "../src/platform/path.ts";
30
30
  import { repairPromptForRun, workflowPromptForRun } from "../src/harness/prompt.ts";
31
31
  import { recordVerifyResult, type VerifyResultOutcome } from "../src/harness/repair.ts";
32
32
  import { isSubagentChild, subagentRoleFromEnv, subagentToolCallBlockReason } from "../src/harness/delegation.ts";
33
- import { questionAskBlockReason } from "../src/harness/question-memory.ts";
33
+ import { questionAskBlockReason, questionFactLabelMismatchReason } from "../src/harness/question-memory.ts";
34
+ import { formatFactLabelCatalog } from "../src/harness/prompt-policy.ts";
34
35
 
35
36
  function requireRun(cwd: string): ReturnType<typeof readActiveRun> {
36
37
  return readActiveRun(cwd);
@@ -175,9 +176,9 @@ const kdQuestionTool = defineTool({
175
176
  name: "kd_question",
176
177
  label: "KD 问题",
177
178
  description:
178
- "创建、回答、修订或列出金蝶 Harness 结构化问题。每次必须只记录一个最阻塞的短问题。",
179
+ "创建、回答、修订、列出金蝶 Harness 结构化问题,或列出合法 factLabel。每次必须只记录一个最阻塞的短问题。",
179
180
  parameters: Type.Object({
180
- action: Type.Optional(Type.String({ description: "操作类型:ask、answer、revise 或 list,默认 ask。" })),
181
+ action: Type.Optional(Type.String({ description: "操作类型:ask、answer、revise、listlabels,默认 ask。" })),
181
182
  id: Type.Optional(Type.String({ description: "回答问题时的问题编号,例如 Q-001。" })),
182
183
  question: Type.Optional(Type.String({ description: "提问内容;必须是一个短问题。" })),
183
184
  answer: Type.Optional(Type.String({ description: "用户答案,action=answer 时使用。" })),
@@ -198,6 +199,9 @@ const kdQuestionTool = defineTool({
198
199
  }
199
200
 
200
201
  const action = (params.action ?? "ask").toLowerCase();
202
+ if (action === "labels") {
203
+ return { content: [{ type: "text", text: formatFactLabelCatalog() }], details: { action: "labels" } };
204
+ }
201
205
  if (action === "list") {
202
206
  const text = formatQuestions(run);
203
207
  return { content: [{ type: "text", text }], details: { questions: run.questions ?? [] } };
@@ -260,7 +264,7 @@ const kdQuestionTool = defineTool({
260
264
 
261
265
  if (action !== "ask") {
262
266
  return {
263
- content: [{ type: "text", text: `未知 kd_question action:${params.action}。有效值:ask、answer、revise、list。` }],
267
+ content: [{ type: "text", text: `未知 kd_question action:${params.action}。有效值:ask、answer、revise、list、labels。` }],
264
268
  details: { error: "unknown-action", action: params.action },
265
269
  };
266
270
  }
@@ -291,6 +295,13 @@ const kdQuestionTool = defineTool({
291
295
  details: { error: "batched-question" },
292
296
  };
293
297
  }
298
+ const mismatchReason = questionFactLabelMismatchReason({ question: params.question, factLabel: params.factLabel });
299
+ if (mismatchReason) {
300
+ return {
301
+ content: [{ type: "text", text: mismatchReason }],
302
+ details: { error: "fact-label-question-mismatch", factLabel: params.factLabel },
303
+ };
304
+ }
294
305
  const askBlockReason = questionAskBlockReason(run, { factLabel: params.factLabel });
295
306
  if (askBlockReason) {
296
307
  return {
@@ -1,6 +1,7 @@
1
1
  import { dirname, join, extname } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { readFileSync, readFile as fsReadFile } from "node:fs";
4
+ import { spawn } from "node:child_process";
4
5
  import { Type } from "@earendil-works/pi-ai";
5
6
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
7
  import { formatSearchResults, formatTableSchema } from "../src/knowledge/format.ts";
@@ -25,6 +26,7 @@ import { resolveWorkspacePath } from "../src/platform/path.ts";
25
26
  import { advanceRunIfReady, readActiveRun } from "../src/harness/state.ts";
26
27
  import { writeEvidenceFile } from "../src/harness/evidence.ts";
27
28
  import { SDK_SIGNATURE_EVIDENCE } from "../src/harness/sdk-policy.ts";
29
+ import { findFiles, formatFileSearchResults, listDirectory } from "../src/tools/file-search.ts";
28
30
 
29
31
  const extensionDir = dirname(fileURLToPath(import.meta.url));
30
32
  const knowledgePath = join(extensionDir, "..", "knowledge");
@@ -479,6 +481,58 @@ const kdDocReadTool = defineTool({
479
481
  },
480
482
  });
481
483
 
484
+ const kdFindFileTool = defineTool({
485
+ name: "kd_find_file",
486
+ label: "KD 文件查找",
487
+ description: "在 Windows/PowerShell 环境下按文件名递归查找文件,不依赖 Pi 内置 bash。查找源码文件时优先使用此工具。",
488
+ parameters: Type.Object({
489
+ name: Type.String({ description: "文件名或通配符,例如 MesApiClient.cs 或 *.cs。" }),
490
+ root: Type.Optional(Type.String({ description: "搜索根目录;支持项目相对路径或 Windows 绝对路径。默认当前项目根。" })),
491
+ maxResults: Type.Optional(Type.Number({ description: "最大结果数,默认 50,最多 500。" })),
492
+ }),
493
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
494
+ try {
495
+ const results = findFiles(ctx.cwd, { root: params.root, name: params.name, maxResults: params.maxResults });
496
+ return { content: [{ type: "text", text: formatFileSearchResults(results) }], details: { count: results.length, root: params.root ?? ".", name: params.name } };
497
+ } catch (error) {
498
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], details: { error: "find-file-failed" } };
499
+ }
500
+ },
501
+ });
502
+
503
+ const kdListDirTool = defineTool({
504
+ name: "kd_list_dir",
505
+ label: "KD 目录列表",
506
+ description: "列出目录内容,不读取目录为文件,不依赖 Pi 内置 bash。探索项目结构时优先使用此工具。",
507
+ parameters: Type.Object({
508
+ path: Type.Optional(Type.String({ description: "目录路径;支持项目相对路径或 Windows 绝对路径。默认当前项目根。" })),
509
+ maxEntries: Type.Optional(Type.Number({ description: "最大返回条目数,默认 50,最多 500。" })),
510
+ }),
511
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
512
+ try {
513
+ const results = listDirectory(ctx.cwd, { path: params.path, maxEntries: params.maxEntries });
514
+ return { content: [{ type: "text", text: formatFileSearchResults(results) }], details: { count: results.length, path: params.path ?? "." } };
515
+ } catch (error) {
516
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], details: { error: "list-dir-failed" } };
517
+ }
518
+ },
519
+ });
520
+
521
+ const windowsPowerShellBashTool = defineTool({
522
+ name: "bash",
523
+ label: "PowerShell",
524
+ description:
525
+ "Windows KCode shell override. Execute a PowerShell command in the current working directory. Do not use Linux/bash syntax; use PowerShell commands such as Get-ChildItem, Select-String, Get-Content, and dotnet.",
526
+ promptSnippet: "Execute Windows PowerShell commands. Use PowerShell syntax, not bash syntax.",
527
+ parameters: Type.Object({
528
+ command: Type.String({ description: "PowerShell command to execute. Use PowerShell syntax, not bash syntax." }),
529
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds." })),
530
+ }),
531
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
532
+ return runPowerShell(params.command, ctx.cwd, params.timeout, signal);
533
+ },
534
+ });
535
+
482
536
  async function extractPdf(filePath: string, maxPages: number): Promise<string> {
483
537
  const { PDFParse } = await import("pdf-parse");
484
538
  const buffer = readFileSync(filePath);
@@ -558,6 +612,7 @@ function extractCsv(filePath: string, maxRows: number): string {
558
612
  }
559
613
 
560
614
  export default function (pi: ExtensionAPI) {
615
+ if (process.platform === "win32") pi.registerTool(windowsPowerShellBashTool);
561
616
  pi.registerTool(kdSearchTool);
562
617
  pi.registerTool(kdTableTool);
563
618
  pi.registerTool(kdCheckTool);
@@ -569,6 +624,8 @@ export default function (pi: ExtensionAPI) {
569
624
  pi.registerTool(kdBuildTool);
570
625
  pi.registerTool(kdDebugTool);
571
626
  pi.registerTool(kdDocReadTool);
627
+ pi.registerTool(kdFindFileTool);
628
+ pi.registerTool(kdListDirTool);
572
629
  }
573
630
 
574
631
  function writeSdkSignatureEvidence(cwd: string, content: string): string | undefined {
@@ -593,3 +650,47 @@ function writeSdkSignatureEvidence(cwd: string, content: string): string | undef
593
650
  { kind: "sdk-signature", command: "kd_sdk_signature", exitCode: 0 },
594
651
  );
595
652
  }
653
+
654
+ function runPowerShell(command: string, cwd: string, timeout: number | undefined, signal: AbortSignal | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
655
+ return new Promise((resolve) => {
656
+ const shell = process.env.ComSpec?.replace(/cmd\.exe$/i, "WindowsPowerShell\\v1.0\\powershell.exe") || "powershell.exe";
657
+ const child = spawn(shell, ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], {
658
+ cwd,
659
+ windowsHide: true,
660
+ stdio: ["ignore", "pipe", "pipe"],
661
+ });
662
+ let stdout = "";
663
+ let stderr = "";
664
+ let timedOut = false;
665
+ const timeoutHandle =
666
+ timeout && timeout > 0
667
+ ? setTimeout(() => {
668
+ timedOut = true;
669
+ child.kill();
670
+ }, timeout * 1000)
671
+ : undefined;
672
+ const onAbort = () => child.kill();
673
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
674
+ child.stdout?.on("data", (data) => {
675
+ stdout += data.toString();
676
+ });
677
+ child.stderr?.on("data", (data) => {
678
+ stderr += data.toString();
679
+ });
680
+ child.on("error", (error) => {
681
+ if (timeoutHandle) clearTimeout(timeoutHandle);
682
+ if (signal) signal.removeEventListener("abort", onAbort);
683
+ resolve({ content: [{ type: "text", text: `PowerShell 启动失败:${error.message}` }], details: { shell: "powershell", error: "spawn-failed" } });
684
+ });
685
+ child.on("close", (exitCode) => {
686
+ if (timeoutHandle) clearTimeout(timeoutHandle);
687
+ if (signal) signal.removeEventListener("abort", onAbort);
688
+ const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
689
+ const suffix = timedOut ? `\n\n[PowerShell timeout after ${timeout}s]` : "";
690
+ resolve({
691
+ content: [{ type: "text", text: `${output || "(no output)"}${suffix}` }],
692
+ details: { shell: "powershell", exitCode, timedOut, command },
693
+ });
694
+ });
695
+ });
696
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "面向金蝶开发的 Pi Coding Agent 启动器、工具包和 Harness 工作流",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli/kcode.ts CHANGED
@@ -12,6 +12,7 @@ export interface KcodeCliResult {
12
12
 
13
13
  interface PiSettings {
14
14
  packages?: string[];
15
+ shellPath?: string;
15
16
  [key: string]: unknown;
16
17
  }
17
18
 
@@ -100,6 +101,7 @@ export function doctor(cwd: string, args: string[] = []): KcodeCliResult {
100
101
  lines.push(`KCode package:${packageRoot}`);
101
102
  lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,运行 kcode init"}`);
102
103
  lines.push(`项目上下文:${existsSync(projectContextPath) ? projectContextPath : "未创建,运行 kcode context"}`);
104
+ lines.push(`Windows bash:${process.platform === "win32" ? formatWindowsBashStatus(readSettingsSafe(settingsPath)) : "不适用"}`);
103
105
 
104
106
  if (existsSync(settingsPath)) {
105
107
  const settingsResult = readSettingsSafe(settingsPath);
@@ -135,6 +137,10 @@ export function doctor(cwd: string, args: string[] = []): KcodeCliResult {
135
137
  warnings++;
136
138
  lines.push("[WARN] 项目上下文不存在,运行 kcode context --refresh 或 kcode repair。");
137
139
  }
140
+ if (process.platform === "win32" && !findWindowsBash(readSettingsSafe(settingsPath))) {
141
+ warnings++;
142
+ lines.push("[WARN] Pi 内置 bash 在当前 Windows 环境不可用;KCode 已覆盖 bash 为 PowerShell,并提供 kd_find_file/kd_list_dir。安装 Git for Windows 可恢复 Pi 原生 bash。");
143
+ }
138
144
  const npmPrefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
139
145
  const npmRoot = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
140
146
  lines.push(`npm prefix -g:${npmPrefix.status === 0 ? npmPrefix.stdout.trim() : "不可用"}`);
@@ -351,3 +357,24 @@ function helpText(): string {
351
357
  " kcode start 初始化项目配置后启动 KCode 工作环境",
352
358
  ].join("\n");
353
359
  }
360
+
361
+ function formatWindowsBashStatus(settingsResult: ReturnType<typeof readSettingsSafe>): string {
362
+ const bash = findWindowsBash(settingsResult);
363
+ return bash ? `可用(${bash})` : "不可用;KCode 将使用 PowerShell 覆盖工具和 kd_find_file/kd_list_dir";
364
+ }
365
+
366
+ function findWindowsBash(settingsResult?: ReturnType<typeof readSettingsSafe>): string | undefined {
367
+ const configured = settingsResult?.ok && typeof settingsResult.settings.shellPath === "string" ? settingsResult.settings.shellPath : undefined;
368
+ if (configured && existsSync(configured)) return configured;
369
+ const candidates = [
370
+ process.env.ProgramFiles ? join(process.env.ProgramFiles, "Git", "bin", "bash.exe") : undefined,
371
+ process.env["ProgramFiles(x86)"] ? join(process.env["ProgramFiles(x86)"]!, "Git", "bin", "bash.exe") : undefined,
372
+ ...pathEntries().map((entry) => join(entry, "bash.exe")),
373
+ ].filter((item): item is string => Boolean(item));
374
+ return candidates.find((candidate) => existsSync(candidate));
375
+ }
376
+
377
+ function pathEntries(): string[] {
378
+ const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
379
+ return (process.env[pathKey] ?? "").split(";").filter(Boolean);
380
+ }
@@ -157,6 +157,7 @@ export const PROJECT_PERSISTENT_RULES = [
157
157
  "企业版 Python 插件通常没有本地构建可替代验证;BOS 注册、外部系统操作、人工功能测试和生产环境验证必须由用户提供可核验证据并记录来源。",
158
158
  "提示语集中管理为正式工程指令;禁止口语化、闲聊式、鼓励式提示词进入运行时规则。",
159
159
  "工具使用必须匹配当前会话实际可用工具和操作系统;Windows 默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`,禁止假设 bash 可用。",
160
+ "Windows 查找文件和目录时优先使用 `kd_find_file`、`kd_list_dir` 或 PowerShell;禁止用 Linux find/ls 语法反复调用 Pi 内置 bash。",
160
161
  "文件定位必须使用真实搜索或目录读取结果;工具不可用时明确说明阻塞原因和需要用户执行的命令,禁止猜测路径、反复自述工具失败或用 kd_subagent 代替基础文件搜索。",
161
162
  "不做旧状态迁移兼容或旧问题答案推断;坏状态只过滤,缺失事实必须重新提问确认。",
162
163
  ];
@@ -183,7 +184,7 @@ export const CORE_WORKFLOW_CONSTRAINTS = [
183
184
  "evidence 必须记录命令、Exit 和关键输出;命令无法运行时记录阻塞原因。",
184
185
  "外部系统操作、BOS 注册、人工功能测试和生产环境验证不能由 LLM 代办;必须要求用户提供验证结果或可核验证据,并记录证据来源。",
185
186
  "Windows 路径规则:项目相对路径为默认;绝对路径使用 D:\\... 形式。",
186
- "工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,可用 PowerShell/rg/Get-ChildItem/Get-Content;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
187
+ "工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,优先用 kd_find_file/kd_list_dir/PowerShell;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
187
188
  ];
188
189
 
189
190
  export const PHASE_GUIDANCE: Record<KdPhase, string> = {
@@ -214,6 +215,25 @@ export function fieldLabels(fields: ContractField[]): string[] {
214
215
  return fields.map((field) => field.label);
215
216
  }
216
217
 
218
+ export function allFactLabels(): string[] {
219
+ return fieldLabels([...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS]);
220
+ }
221
+
222
+ export function formatFactLabelCatalog(): string {
223
+ return [
224
+ "## 实现契约事实标签",
225
+ ...formatPromptLines(fieldLabels(IMPLEMENTATION_CONTRACT_FIELDS)),
226
+ "",
227
+ "## 数据源上下文事实标签",
228
+ ...formatPromptLines(fieldLabels(DATA_SOURCE_CONTEXT_FIELDS)),
229
+ "",
230
+ "## 第三方对接事实标签",
231
+ ...formatPromptLines(fieldLabels(INTEGRATION_CONTEXT_FIELDS)),
232
+ "",
233
+ "产品类型/产品画像不是 factLabel;使用 /kd-product <产品> 或先登记无 factLabel 的产品确认问题。",
234
+ ].join("\n");
235
+ }
236
+
217
237
  export function questionForMissingLabel(label: string): string | undefined {
218
238
  const fields = [...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS];
219
239
  return fields.find((field) => field.label === label)?.question;
@@ -1,5 +1,5 @@
1
1
  import type { ActiveRun, KdFact, KdQuestion } from "./types.ts";
2
- import { canonicalFactLabel } from "./prompt-policy.ts";
2
+ import { allFactLabels, canonicalFactLabel, formatFactLabelCatalog } from "./prompt-policy.ts";
3
3
 
4
4
  export function formatQuestionMemory(run: ActiveRun): string {
5
5
  const questions = Array.isArray(run.questions) ? run.questions : [];
@@ -98,7 +98,7 @@ export function questionAskBlockReason(run: ActiveRun, input: { factLabel?: stri
98
98
  const rawLabel = input.factLabel?.trim();
99
99
  if (!rawLabel) return undefined;
100
100
  const label = canonicalFactLabel(rawLabel);
101
- if (!label) return `未知 factLabel:${rawLabel}。必须使用 KCode 集中定义的实现契约、数据源上下文或第三方对接事实标签。`;
101
+ if (!label) return [`未知 factLabel:${rawLabel}。必须使用 KCode 集中定义的实现契约、数据源上下文或第三方对接事实标签。`, "", "执行 `kd_question action=labels` 查看完整合法标签。", "", formatFactLabelCatalog()].join("\n");
102
102
  const key = factKey(label);
103
103
  const open = (Array.isArray(run.questions) ? run.questions : []).find(
104
104
  (question) => question.status === "open" && question.blocking && question.factLabel && factKey(question.factLabel) === key,
@@ -113,6 +113,26 @@ export function questionAskBlockReason(run: ActiveRun, input: { factLabel?: stri
113
113
  return undefined;
114
114
  }
115
115
 
116
+ export function questionFactLabelMismatchReason(input: { question: string; factLabel?: string }): string | undefined {
117
+ const question = input.question.trim();
118
+ const label = input.factLabel?.trim() ? canonicalFactLabel(input.factLabel) : undefined;
119
+ if (!label) return undefined;
120
+ const asksProduct = /产品类型|产品画像|金蝶产品|技术栈|苍穹|星瀚|星空旗舰版|旗舰版|企业版|Cloud\s*BOS|Cosmic|BOS/i.test(question);
121
+ const asksOtherFact = allFactLabels().some((candidate) => candidate !== label && labelAppearsInQuestion(question, candidate));
122
+ const conjunction = /以及|和|并|同时|还有|及|\/|、|,|,/.test(question);
123
+ if (asksProduct && (asksOtherFact || conjunction)) {
124
+ return [
125
+ `问题文本同时要求确认产品类型和 ${label},但 factLabel 只能记录一个结构化事实。`,
126
+ "产品类型/产品画像不是 factLabel;先使用 /kd-product <产品>,或登记一个无 factLabel 的产品确认问题。",
127
+ `确认产品后,再单独登记 ${label}。`,
128
+ ].join("\n");
129
+ }
130
+ if (asksOtherFact && conjunction) {
131
+ return `问题文本包含多个结构化事实,但 factLabel 只能是一个:${label}。请拆成多个问题,每次只登记当前最阻塞的一个。`;
132
+ }
133
+ return undefined;
134
+ }
135
+
116
136
  export function invalidFactValueReason(value: string): string | undefined {
117
137
  return isInvalidFactValue(value) ? "事实值不是可核验内容,不能使用待确认、未知、按实际环境、TODO/TBD、单独确认/否定或口头确认语解除门禁。" : undefined;
118
138
  }
@@ -218,3 +238,15 @@ function normalizePersistedFact(fact: KdFact): KdFact[] {
218
238
  if (!label || !value || invalidFactValueReason(value)) return [];
219
239
  return [{ ...fact, key: factKey(label), label, value }];
220
240
  }
241
+
242
+ function labelAppearsInQuestion(question: string, label: string): boolean {
243
+ const normalizedQuestion = question.toLowerCase().replace(/\s+/g, "");
244
+ const normalizedLabel = label.toLowerCase().replace(/\s+/g, "");
245
+ if (normalizedQuestion.includes(normalizedLabel)) return true;
246
+ if (label === "目标 FormId/单据或表单标识") return /formid|formid|单据标识|表单标识|目标单据|目标表单/i.test(question);
247
+ if (label === "插件类型和触发事件") return /插件类型|触发事件|生命周期|挂载点|审核事件|保存事件/i.test(question);
248
+ if (label === "字段/实体/分录标识") return /字段标识|实体标识|分录标识|字段|实体|单据体/i.test(question);
249
+ if (label === "数据读取写入方式") return /读取方式|写入方式|读写|取数|数据访问|SQL|KSQL/i.test(question);
250
+ if (label === "SQL/KSQL 表名和数据库字段名") return /表名|数据库字段名|SQL字段|KSQL字段/i.test(question);
251
+ return false;
252
+ }
@@ -0,0 +1,107 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
+ import { resolveWorkspacePath } from "../platform/path.ts";
4
+
5
+ const IGNORED_DIRS = new Set([".git", ".idea", ".pi", ".tmp", ".vscode", "bin", "build", "dist", "node_modules", "obj", "out", "target"]);
6
+ const DEFAULT_MAX_RESULTS = 50;
7
+ const MAX_ENTRIES = 50000;
8
+
9
+ export interface FileSearchResult {
10
+ path: string;
11
+ type: "file" | "directory";
12
+ size?: number;
13
+ }
14
+
15
+ export function findFiles(cwd: string, input: { root?: string; name: string; maxResults?: number }): FileSearchResult[] {
16
+ const root = resolveWorkspacePath(cwd, input.root || ".");
17
+ const pattern = input.name.trim();
18
+ if (!pattern) throw new Error("kd_find_file requires a non-empty name.");
19
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Search root is not a directory: ${root}`);
20
+
21
+ const maxResults = clampLimit(input.maxResults);
22
+ const matcher = wildcardMatcher(pattern);
23
+ const results: FileSearchResult[] = [];
24
+ const queue = [root];
25
+ let visited = 0;
26
+
27
+ while (queue.length > 0 && results.length < maxResults && visited < MAX_ENTRIES) {
28
+ const current = queue.shift();
29
+ if (!current) break;
30
+
31
+ let children;
32
+ try {
33
+ children = readdirSync(current, { withFileTypes: true });
34
+ } catch {
35
+ continue;
36
+ }
37
+
38
+ for (const child of children) {
39
+ if (results.length >= maxResults || visited >= MAX_ENTRIES) break;
40
+ const fullPath = join(current, child.name);
41
+ visited++;
42
+
43
+ if (child.isDirectory()) {
44
+ if (!IGNORED_DIRS.has(child.name)) queue.push(fullPath);
45
+ if (matcher(child.name)) results.push({ path: displayPath(cwd, fullPath), type: "directory" });
46
+ continue;
47
+ }
48
+
49
+ if (child.isFile() && matcher(child.name)) {
50
+ let size: number | undefined;
51
+ try {
52
+ size = statSync(fullPath).size;
53
+ } catch {
54
+ size = undefined;
55
+ }
56
+ results.push({ path: displayPath(cwd, fullPath), type: "file", size });
57
+ }
58
+ }
59
+ }
60
+
61
+ return results;
62
+ }
63
+
64
+ export function listDirectory(cwd: string, input: { path?: string; maxEntries?: number }): FileSearchResult[] {
65
+ const dir = resolveWorkspacePath(cwd, input.path || ".");
66
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`Path is not a directory: ${dir}`);
67
+ const maxEntries = clampLimit(input.maxEntries);
68
+ return readdirSync(dir, { withFileTypes: true })
69
+ .slice(0, maxEntries)
70
+ .map((entry) => {
71
+ const fullPath = join(dir, entry.name);
72
+ if (entry.isDirectory()) return { path: displayPath(cwd, fullPath), type: "directory" as const };
73
+ let size: number | undefined;
74
+ try {
75
+ size = statSync(fullPath).size;
76
+ } catch {
77
+ size = undefined;
78
+ }
79
+ return { path: displayPath(cwd, fullPath), type: "file" as const, size };
80
+ });
81
+ }
82
+
83
+ export function formatFileSearchResults(results: FileSearchResult[]): string {
84
+ if (results.length === 0) return "未找到匹配项。";
85
+ return results.map((item) => `- ${item.type === "directory" ? "[dir]" : "[file]"} ${item.path}${item.size === undefined ? "" : ` (${item.size} bytes)`}`).join("\n");
86
+ }
87
+
88
+ function clampLimit(value: number | undefined): number {
89
+ return Math.max(1, Math.min(Math.trunc(value ?? DEFAULT_MAX_RESULTS), 500));
90
+ }
91
+
92
+ function displayPath(cwd: string, fullPath: string): string {
93
+ const rel = relative(cwd, fullPath);
94
+ if (rel && !rel.startsWith("..") && !/^[a-zA-Z]:/.test(rel)) return rel.replace(/\//g, "\\");
95
+ return fullPath;
96
+ }
97
+
98
+ function wildcardMatcher(pattern: string): (name: string) => boolean {
99
+ const normalized = pattern.trim();
100
+ if (!/[*?]/.test(normalized)) {
101
+ const target = normalized.toLowerCase();
102
+ return (name) => basename(name).toLowerCase() === target;
103
+ }
104
+ const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
105
+ const regex = new RegExp(`^${escaped}$`, "i");
106
+ return (name) => regex.test(basename(name));
107
+ }