kcode-pi 0.1.18 → 0.1.20

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/README.md CHANGED
@@ -293,6 +293,7 @@ kcode start --provider openai --model gpt-4o
293
293
  ```text
294
294
  /kd-start [--product 产品] [--version 版本] <需求>
295
295
  /kd-product <产品> [--version 版本]
296
+ /kd-risk <low|medium|high> <原因>
296
297
  /kd-status
297
298
  /kd-runs
298
299
  /kd-switch <run-id>
@@ -300,7 +301,7 @@ kcode start --provider openai --model gpt-4o
300
301
  /kd-finish
301
302
  /kd-gate
302
303
  /kd-advance [阶段]
303
- /kd-artifact [阶段] [内容]
304
+ /kd-artifact [阶段] [内容] [--replace]
304
305
  /kd-answer Q-001 <答案>
305
306
  ```
306
307
 
@@ -312,6 +313,14 @@ kcode start --provider openai --model gpt-4o
312
313
 
313
314
  `/kd-start` 会创建新的功能点 run,并立即触发 Agent 进入 `discuss` 阶段。如果未识别出产品画像,例如显示 `(unknown/unknown)`,下一步应先确认产品、版本和技术栈,而不是直接写代码。
314
315
 
316
+ 进入 `ship` 前必须确认风险等级:
317
+
318
+ ```text
319
+ /kd-risk low 已完成本地构建和元数据检查,无残余交付风险
320
+ ```
321
+
322
+ `/kd-artifact` 默认只创建缺失阶段文档。若阶段文档已存在,带内容调用不会直接覆盖;确认要整体替换时必须追加 `--replace`。
323
+
315
324
  暂停后接续:
316
325
 
317
326
  ```text
@@ -376,6 +385,7 @@ KCode 会把金蝶开发需求纳入 Harness 工作流。你可以直接输入
376
385
  .pi/kd/runs/<run-id>/VERIFY.md
377
386
  .pi/kd/runs/<run-id>/SHIP.md
378
387
  .pi/kd/runs/<run-id>/evidence/
388
+ .pi/kd/runs/<run-id>/evidence/index.json
379
389
  ```
380
390
 
381
391
  下次重新 `kcode start` 时,KCode 会提示当前项目的 active run。执行 `/kd-resume` 后,KCode 会读取项目常驻上下文、active run 状态和已生成的阶段文档,再继续当前功能点的当前阶段。已完成或暂停的功能点仍保留在 `.pi/kd/runs/<run-id>/`,可用 `/kd-runs` 查看,用 `/kd-switch <run-id>` 切回。
@@ -403,6 +413,7 @@ ship 汇总变更、验证证据、风险和后续事项
403
413
  - 写生产源码前必须已有 `evidence/tdd-red.md`,内容可以是 API/基类/方法签名检查、元数据检查、编译检查、既有测试框架或外部接口最小验证的失败输出。
404
414
  - 进入 `execute` 后,只允许写入 `PLAN.md` 明确列出的源码文件;如果临时发现要改新文件,必须先回到 plan 更新 `PLAN.md`。
405
415
  - 进入 `verify` 前,`EXECUTION.md` 必须逐个完成 `PLAN.md` 中的所有 `STEP-###`,并为每个步骤记录真实存在的 `evidence/...` 文件;同时必须已有 `evidence/tdd-red.md` 和 `evidence/tdd-green.md`。
416
+ - evidence 文件必须登记在 `evidence/index.json` 中。KCode 内置工具和 evidence 写入路径会自动维护索引;不要手工塞文件绕过门禁。
406
417
  - `evidence/tdd-green.md` 必须包含真实成功输出和 `Exit: 0` 或 `退出码:0`;不能写“需在开发环境验证”“待验证”“未执行”等不确定结论。
407
418
  - 如果门禁提示 evidence 内容无效,不要反复改文案或补关键词;必须重新运行 `PLAN.md` 中声明的真实验证命令,并记录命令、Exit、STDOUT/STDERR 或工具输出。
408
419
  - Java / 苍穹 / 星空旗舰版的语法和编译验证优先使用当前项目 Gradle 命令,例如 `.\gradlew.bat build`、`./gradlew build` 或 `.\gradlew.bat :模块:build`。
@@ -15,7 +15,9 @@ import {
15
15
  switchActiveRun,
16
16
  updateProductProfile,
17
17
  updatePhaseArtifact,
18
+ updateRisk,
18
19
  } from "../src/harness/state.ts";
20
+ import type { KdRisk } from "../src/harness/types.ts";
19
21
  import { readArtifact } from "../src/harness/artifacts.ts";
20
22
  import { flagshipWriteBlockReason, isSourceLikePath, planWriteBlockReason } from "../src/harness/path-policy.ts";
21
23
  import { sdkSignatureProductionWriteBlockReason } from "../src/harness/sdk-policy.ts";
@@ -30,18 +32,19 @@ function requireRun(cwd: string): ReturnType<typeof readActiveRun> {
30
32
  return readActiveRun(cwd);
31
33
  }
32
34
 
33
- function parseArtifactArgs(args: string, currentPhase: KdPhase): { phase: KdPhase; content?: string } | undefined {
34
- const trimmed = args.trim();
35
- if (!trimmed) return { phase: currentPhase };
35
+ function parseArtifactArgs(args: string, currentPhase: KdPhase): { phase: KdPhase; content?: string; replace: boolean } | undefined {
36
+ const replace = /\s*(^|\s)--replace(\s|$)/.test(args);
37
+ const trimmed = args.replace(/\s*(^|\s)--replace(?=\s|$)/g, " ").trim();
38
+ if (!trimmed) return { phase: currentPhase, replace };
36
39
 
37
40
  const [first, ...rest] = trimmed.split(/\s+/);
38
41
  if (isKdPhase(first)) {
39
42
  const contentStart = trimmed.indexOf(first) + first.length;
40
43
  const content = trimmed.slice(contentStart).trim();
41
- return { phase: first, content: content || undefined };
44
+ return { phase: first, content: content || undefined, replace };
42
45
  }
43
46
 
44
- return { phase: currentPhase, content: trimmed };
47
+ return { phase: currentPhase, content: trimmed, replace };
45
48
  }
46
49
 
47
50
  function parseStartArgs(args: string): { goal: string; product?: string; version?: string } {
@@ -73,6 +76,15 @@ function parseProductArgs(args: string): { product: string; version?: string } |
73
76
  return { product, version: parsed.version };
74
77
  }
75
78
 
79
+ function parseRiskArgs(args: string): { risk: KdRisk; reason: string } | undefined {
80
+ const [first, ...rest] = args.trim().split(/\s+/).filter(Boolean);
81
+ const risk = first?.toLowerCase();
82
+ if (risk !== "low" && risk !== "medium" && risk !== "high") return undefined;
83
+ const reason = rest.join(" ").trim();
84
+ if (!reason) return undefined;
85
+ return { risk, reason };
86
+ }
87
+
76
88
  function shouldStartHarnessFromInput(text: string): boolean {
77
89
  if (!text.trim() || text.trim().startsWith("/")) return false;
78
90
  return KINGDEE_INTENT_PATTERN.test(text);
@@ -320,6 +332,9 @@ export default function (pi: ExtensionAPI) {
320
332
  run = createActiveRun(ctx.cwd, event.text);
321
333
  if (ctx.hasUI) {
322
334
  ctx.ui.notify(`已启动 Kingdee Harness run:${run.id}(${run.profile?.product}/${run.profile?.techStack})`, "info");
335
+ if (run.profile?.product === "unknown") {
336
+ ctx.ui.notify("产品画像未识别。下一步先执行 /kd-product <flagship|cosmic|xinghan|cangqiong|enterprise>。", "warning");
337
+ }
323
338
  }
324
339
  }
325
340
 
@@ -390,6 +405,9 @@ export default function (pi: ExtensionAPI) {
390
405
 
391
406
  const run = createActiveRun(ctx.cwd, goal, parsed.product, parsed.version);
392
407
  ctx.ui.notify(`已启动 Kingdee Harness run:${run.id}(${run.profile?.product}/${run.profile?.techStack})`, "info");
408
+ if (run.profile?.product === "unknown") {
409
+ ctx.ui.notify("产品画像未识别。下一步先执行 /kd-product <flagship|cosmic|xinghan|cangqiong|enterprise>。", "warning");
410
+ }
393
411
  sendWorkflowPrompt(pi, ctx, run, `继续 KCode Harness run ${run.id}:${goal}`);
394
412
  },
395
413
  });
@@ -467,6 +485,26 @@ export default function (pi: ExtensionAPI) {
467
485
  },
468
486
  });
469
487
 
488
+ pi.registerCommand("kd-risk", {
489
+ description: "设置当前 Kingdee run 风险等级:/kd-risk <low|medium|high> <原因>",
490
+ handler: async (args, ctx) => {
491
+ const run = requireRun(ctx.cwd);
492
+ if (!run) {
493
+ ctx.ui.notify("当前没有 active Kingdee Harness run。请使用 /kd-start <需求>。", "error");
494
+ return;
495
+ }
496
+
497
+ const risk = parseRiskArgs(args);
498
+ if (!risk) {
499
+ ctx.ui.notify("用法:/kd-risk <low|medium|high> <原因>", "error");
500
+ return;
501
+ }
502
+
503
+ const updated = updateRisk(ctx.cwd, run, risk.risk, risk.reason);
504
+ ctx.ui.notify(`风险等级:${updated.riskAssessment?.level}(${updated.riskAssessment?.reason})`, "info");
505
+ },
506
+ });
507
+
470
508
  pi.registerCommand("kd-advance", {
471
509
  description: `推进 Kingdee run 到下一阶段,或指定阶段:${PHASE_ORDER.join("|")}`,
472
510
  handler: async (args, ctx) => {
@@ -492,7 +530,7 @@ export default function (pi: ExtensionAPI) {
492
530
  });
493
531
 
494
532
  pi.registerCommand("kd-artifact", {
495
- description: "创建或更新阶段文档:/kd-artifact [阶段] [内容]",
533
+ description: "创建阶段文档,或显式覆盖阶段文档:/kd-artifact [阶段] [内容] [--replace]",
496
534
  handler: async (args, ctx) => {
497
535
  const run = requireRun(ctx.cwd);
498
536
  if (!run) {
@@ -502,7 +540,12 @@ export default function (pi: ExtensionAPI) {
502
540
 
503
541
  const parsed = parseArtifactArgs(args, run.phase);
504
542
  if (!parsed) {
505
- ctx.ui.notify("用法:/kd-artifact [阶段] [内容]", "error");
543
+ ctx.ui.notify("用法:/kd-artifact [阶段] [内容] [--replace]", "error");
544
+ return;
545
+ }
546
+
547
+ if (parsed.content && readArtifact(ctx.cwd, run, parsed.phase) !== undefined && !parsed.replace) {
548
+ ctx.ui.notify(`拒绝覆盖 ${parsed.phase} 阶段文档。若确认要整体替换,请追加 --replace;追加内容应让 Agent 更新具体章节。`, "warning");
506
549
  return;
507
550
  }
508
551
 
@@ -1,52 +1,33 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { inspectGate } from "../src/harness/gates.ts";
3
+ import { readActiveRun } from "../src/harness/state.ts";
4
+ import type { ActiveRun, GateResult } from "../src/harness/types.ts";
5
+ import { formatProductProfile } from "../src/product/profile.ts";
4
6
 
5
- type KdPhase = "discuss" | "spec" | "plan" | "execute" | "verify" | "ship";
6
-
7
- interface ActiveRun {
8
- id?: string;
9
- phase?: KdPhase;
10
- product?: string;
11
- profile?: {
12
- product?: string;
13
- techStack?: string;
14
- language?: string;
15
- };
16
- risk?: "low" | "medium" | "high";
17
- gate?: {
18
- passed?: boolean;
19
- reason?: string;
20
- };
7
+ function formatProduct(run: ActiveRun | undefined): string {
8
+ if (!run) return "未选择";
9
+ if (run.profile?.product === "unknown") return "未确认";
10
+ return formatProductProfile(run.profile);
21
11
  }
22
12
 
23
- function readActiveRun(cwd: string): ActiveRun | undefined {
24
- const activeRunPath = join(cwd, ".pi", "kd", "active-run.json");
25
- if (!existsSync(activeRunPath)) return undefined;
26
-
27
- try {
28
- return JSON.parse(readFileSync(activeRunPath, "utf8")) as ActiveRun;
29
- } catch {
30
- return undefined;
31
- }
13
+ function formatPhase(phase: ActiveRun["phase"] | undefined): string {
14
+ return phase ?? "空闲";
32
15
  }
33
16
 
34
- function formatProduct(run: ActiveRun | undefined): string {
35
- if (!run) return "未选择";
36
- const product = run.profile?.product ?? run.product ?? "unknown";
37
- const techStack = run.profile?.techStack ?? "unknown";
38
- const language = run.profile?.language ?? "unknown";
39
- return `${product}/${techStack}/${language}`;
17
+ function formatGate(gate: GateResult | undefined): string {
18
+ if (!gate) return "门禁:待检查";
19
+ return gate.passed ? "门禁:通过" : "门禁:阻塞";
40
20
  }
41
21
 
42
- function formatPhase(phase: ActiveRun["phase"]): string {
43
- return phase ?? "空闲";
22
+ function riskLevel(run: ActiveRun | undefined): string {
23
+ return run?.riskAssessment?.level ?? "未知";
44
24
  }
45
25
 
46
- function formatGate(run: ActiveRun | undefined): string {
47
- if (!run?.gate) return "门禁:待检查";
48
- if (run.gate.passed) return "门禁:通过";
49
- return `门禁:阻塞${run.gate.reason ? ` - ${run.gate.reason}` : ""}`;
26
+ function riskColor(risk: string): "error" | "warning" | "muted" | "success" {
27
+ if (risk === "high") return "error";
28
+ if (risk === "medium") return "warning";
29
+ if (risk === "未知") return "muted";
30
+ return "success";
50
31
  }
51
32
 
52
33
  function padOrTrim(text: string, width: number): string {
@@ -73,10 +54,11 @@ export default function (pi: ExtensionAPI) {
73
54
  return {
74
55
  render(width: number): string[] {
75
56
  const run = readActiveRun(ctx.cwd);
57
+ const gateState = run ? inspectGate(ctx.cwd, run) : undefined;
76
58
  const phase = formatPhase(run?.phase);
77
59
  const product = formatProduct(run);
78
- const gate = formatGate(run);
79
- const risk = run?.risk ?? "未知";
60
+ const gate = formatGate(gateState);
61
+ const risk = riskLevel(run);
80
62
  const runId = run?.id ?? "无";
81
63
 
82
64
  const status = [
@@ -85,9 +67,9 @@ export default function (pi: ExtensionAPI) {
85
67
  theme.fg("muted", " | 产品:"),
86
68
  theme.fg("text", product),
87
69
  theme.fg("muted", " | 风险:"),
88
- theme.fg(risk === "high" ? "error" : risk === "medium" ? "warning" : "success", risk),
70
+ theme.fg(riskColor(risk), risk),
89
71
  theme.fg("muted", " | "),
90
- theme.fg(run?.gate?.passed === false ? "error" : "muted", gate),
72
+ theme.fg(gateState?.passed === false ? "error" : "muted", gate),
91
73
  ].join("");
92
74
 
93
75
  return [
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { readFileSync } from "node:fs";
4
4
  import { Type } from "@earendil-works/pi-ai";
5
5
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { formatSearchResults, formatTableSchema } from "../src/knowledge/format.ts";
@@ -23,7 +23,7 @@ import {
23
23
  } from "../src/official/kingdee-skills.ts";
24
24
  import { resolveWorkspacePath } from "../src/platform/path.ts";
25
25
  import { readActiveRun } from "../src/harness/state.ts";
26
- import { runArtifactPath } from "../src/harness/paths.ts";
26
+ import { writeEvidenceFile } from "../src/harness/evidence.ts";
27
27
  import { SDK_SIGNATURE_EVIDENCE } from "../src/harness/sdk-policy.ts";
28
28
 
29
29
  const extensionDir = dirname(fileURLToPath(import.meta.url));
@@ -433,10 +433,10 @@ function writeSdkSignatureEvidence(cwd: string, content: string): string | undef
433
433
  const run = readActiveRun(cwd);
434
434
  if (!run) return undefined;
435
435
 
436
- const path = runArtifactPath(cwd, run, SDK_SIGNATURE_EVIDENCE);
437
- mkdirSync(dirname(path), { recursive: true });
438
- writeFileSync(
439
- path,
436
+ return writeEvidenceFile(
437
+ cwd,
438
+ run,
439
+ SDK_SIGNATURE_EVIDENCE,
440
440
  [
441
441
  "# SDK 签名证据",
442
442
  "",
@@ -448,7 +448,6 @@ function writeSdkSignatureEvidence(cwd: string, content: string): string | undef
448
448
  "```",
449
449
  "",
450
450
  ].join("\n"),
451
- "utf8",
451
+ { kind: "sdk-signature", command: "kd_sdk_signature", exitCode: 0 },
452
452
  );
453
- return path;
454
453
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "面向金蝶开发的 Pi Coding Agent 启动器、工具包和 Harness 工作流",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,106 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { ActiveRun } from "./types.ts";
4
+ import { runArtifactPath, runRoot } from "./paths.ts";
5
+
6
+ export interface EvidenceEntry {
7
+ path: string;
8
+ kind: string;
9
+ command?: string;
10
+ exitCode?: number;
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ size: number;
14
+ }
15
+
16
+ export interface EvidenceIndex {
17
+ version: 1;
18
+ entries: EvidenceEntry[];
19
+ }
20
+
21
+ export const EVIDENCE_INDEX = "evidence/index.json";
22
+
23
+ export function evidenceIndexPath(cwd: string, run: ActiveRun): string {
24
+ return runArtifactPath(cwd, run, EVIDENCE_INDEX);
25
+ }
26
+
27
+ export function readEvidenceIndex(cwd: string, run: ActiveRun): EvidenceIndex {
28
+ const path = evidenceIndexPath(cwd, run);
29
+ if (!existsSync(path)) return { version: 1, entries: [] };
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as EvidenceIndex;
32
+ return parsed.version === 1 && Array.isArray(parsed.entries) ? parsed : { version: 1, entries: [] };
33
+ } catch {
34
+ return { version: 1, entries: [] };
35
+ }
36
+ }
37
+
38
+ export function hasEvidenceEntry(cwd: string, run: ActiveRun, path: string): boolean {
39
+ const normalized = normalizeEvidencePath(path);
40
+ return readEvidenceIndex(cwd, run).entries.some((entry) => normalizeEvidencePath(entry.path) === normalized);
41
+ }
42
+
43
+ export function writeEvidenceFile(
44
+ cwd: string,
45
+ run: ActiveRun,
46
+ path: string,
47
+ content: string,
48
+ options: { kind?: string; command?: string; exitCode?: number } = {},
49
+ ): string {
50
+ const normalized = normalizeEvidencePath(path);
51
+ if (!normalized.startsWith("evidence/") || normalized === EVIDENCE_INDEX) {
52
+ throw new Error(`非法 evidence 路径:${path}`);
53
+ }
54
+
55
+ const absolutePath = runArtifactPath(cwd, run, normalized);
56
+ mkdirSync(dirname(absolutePath), { recursive: true });
57
+ writeFileSync(absolutePath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
58
+ recordEvidence(cwd, run, normalized, options);
59
+ return absolutePath;
60
+ }
61
+
62
+ export function recordEvidence(
63
+ cwd: string,
64
+ run: ActiveRun,
65
+ path: string,
66
+ options: { kind?: string; command?: string; exitCode?: number } = {},
67
+ ): void {
68
+ const normalized = normalizeEvidencePath(path);
69
+ const absolutePath = join(runRoot(cwd, run), normalized);
70
+ if (!existsSync(absolutePath)) return;
71
+
72
+ const now = new Date().toISOString();
73
+ const size = statSync(absolutePath).size;
74
+ const index = readEvidenceIndex(cwd, run);
75
+ const existing = index.entries.find((entry) => normalizeEvidencePath(entry.path) === normalized);
76
+ if (existing) {
77
+ existing.kind = options.kind ?? existing.kind;
78
+ existing.command = options.command ?? existing.command;
79
+ existing.exitCode = options.exitCode ?? existing.exitCode;
80
+ existing.updatedAt = now;
81
+ existing.size = size;
82
+ } else {
83
+ index.entries.push({
84
+ path: normalized,
85
+ kind: options.kind ?? inferKind(normalized),
86
+ command: options.command,
87
+ exitCode: options.exitCode,
88
+ createdAt: now,
89
+ updatedAt: now,
90
+ size,
91
+ });
92
+ }
93
+
94
+ const indexPath = evidenceIndexPath(cwd, run);
95
+ mkdirSync(dirname(indexPath), { recursive: true });
96
+ writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8");
97
+ }
98
+
99
+ function inferKind(path: string): string {
100
+ const name = path.split("/").at(-1) ?? path;
101
+ return name.replace(/\.(md|txt|json)$/i, "");
102
+ }
103
+
104
+ function normalizeEvidencePath(path: string): string {
105
+ return path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
106
+ }
@@ -21,7 +21,7 @@ export function formatStatus(cwd: string, run: ActiveRun | undefined): string {
21
21
  `下一阶段:${next}`,
22
22
  `产品:${formatProductProfile(refreshed.profile)}`,
23
23
  `版本:${refreshed.version ?? "未选择"}`,
24
- `风险:${refreshed.risk ?? "未知"}`,
24
+ `风险:${formatRisk(refreshed)}`,
25
25
  `门禁:${refreshed.gate.passed ? "通过" : "阻塞"}`,
26
26
  refreshed.gate.reason ? `原因:${refreshed.gate.reason}` : undefined,
27
27
  "",
@@ -30,3 +30,9 @@ export function formatStatus(cwd: string, run: ActiveRun | undefined): string {
30
30
  .filter(Boolean)
31
31
  .join("\n");
32
32
  }
33
+
34
+ function formatRisk(run: ActiveRun): string {
35
+ const level = run.riskAssessment?.level ?? "未知";
36
+ const reason = run.riskAssessment?.reason?.trim();
37
+ return reason ? `${level}(${reason})` : level;
38
+ }
@@ -9,6 +9,7 @@ import { executionStepsBlockReason, planStepsBlockReason } from "./plan-steps.ts
9
9
  import { tddPlanBlockReason, tddVerifyBlockReason } from "./tdd-policy.ts";
10
10
  import { SDK_SIGNATURE_EVIDENCE, hasValidSdkSignatureEvidence, requiresSdkSignatureEvidence } from "./sdk-policy.ts";
11
11
  import { runRoot } from "./paths.ts";
12
+ import { EVIDENCE_INDEX, hasEvidenceEntry } from "./evidence.ts";
12
13
 
13
14
  const REQUIRED_MARKERS: Partial<Record<KdPhase, string[]>> = {
14
15
  plan: ["## 验证命令"],
@@ -21,77 +22,79 @@ const COSMIC_METADATA_EVIDENCE = "evidence/cosmic-metadata.json";
21
22
  const COSMIC_API_EVIDENCE = "evidence/cosmic-api.txt";
22
23
  const KSQL_LINT_EVIDENCE = "evidence/ksql-lint.txt";
23
24
 
24
- export function inspectGate(cwd: string, run: ActiveRun): GateResult {
25
- const currentIndex = PHASE_ORDER.indexOf(run.phase);
26
- const missing: string[] = [];
27
-
28
- for (let i = 0; i <= currentIndex; i++) {
29
- const phase = PHASE_ORDER[i];
30
- const artifact = PHASE_ARTIFACTS[phase];
31
- if (!artifactExists(cwd, run, artifact)) missing.push(artifact);
32
- }
25
+ type GateMode = "inspect" | "enter";
33
26
 
34
- const markerProblem = inspectMarkers(cwd, run, run.phase);
35
- const stepProblem = inspectStepState(cwd, run, run.phase);
36
- const evidenceProblem = inspectEvidence(cwd, run, run.phase);
37
- const questionProblem = inspectOpenQuestions(run);
38
- const reasonParts = [];
39
- if (missing.length > 0) reasonParts.push(missingArtifactsReason([...new Set(missing)]));
40
- if (markerProblem) reasonParts.push(markerProblem);
41
- if (stepProblem) reasonParts.push(stepProblem);
42
- if (evidenceProblem) reasonParts.push(evidenceProblem);
43
- if (questionProblem) reasonParts.push(questionProblem);
44
-
45
- return {
46
- passed: reasonParts.length === 0,
47
- reason: reasonParts.length > 0 ? reasonParts.join("; ") : undefined,
48
- checkedAt: new Date().toISOString(),
49
- };
27
+ export function inspectGate(cwd: string, run: ActiveRun): GateResult {
28
+ return gateResult(collectGateProblems(cwd, run, run.phase, "inspect"));
50
29
  }
51
30
 
52
31
  export function canEnterPhase(cwd: string, run: ActiveRun, target: KdPhase): GateResult {
53
- const targetIndex = PHASE_ORDER.indexOf(target);
32
+ return gateResult(collectGateProblems(cwd, run, target, "enter"));
33
+ }
34
+
35
+ function collectGateProblems(cwd: string, run: ActiveRun, phase: KdPhase, mode: GateMode): string[] {
36
+ const phaseIndex = PHASE_ORDER.indexOf(phase);
54
37
  const missing: string[] = [];
55
38
  const reasonParts: string[] = [];
56
39
 
57
- if (target !== "discuss" && !isKnownProduct(run.profile?.product ?? run.product)) {
58
- reasonParts.push(
59
- "不能离开 discuss:产品画像未知。下一步:根据需求或用户回答确认产品,然后执行 /kd-product <flagship|cosmic|xinghan|cangqiong|enterprise>;如果无法判断,先用 kd_question 只问一个最阻塞的产品确认问题。",
60
- );
40
+ if (phase !== "discuss" && !isKnownProduct(run.profile?.product ?? run.product)) {
41
+ reasonParts.push(unknownProductReason());
61
42
  }
62
43
 
63
- for (let i = 0; i < targetIndex; i++) {
64
- const phase = PHASE_ORDER[i];
65
- const artifact = PHASE_ARTIFACTS[phase];
44
+ if (phaseIndex >= PHASE_ORDER.indexOf("ship") && !hasRiskAssessment(cwd, run)) {
45
+ reasonParts.push(unknownRiskReason());
46
+ }
47
+
48
+ const lastRequiredArtifactIndex = mode === "inspect" ? phaseIndex : phaseIndex - 1;
49
+ for (let i = 0; i <= lastRequiredArtifactIndex; i++) {
50
+ const requiredPhase = PHASE_ORDER[i];
51
+ const artifact = PHASE_ARTIFACTS[requiredPhase];
66
52
  if (!artifactExists(cwd, run, artifact)) missing.push(artifact);
67
53
  }
68
54
 
69
- if (target === "execute" && !artifactExists(cwd, run, PHASE_ARTIFACTS.plan)) {
55
+ if (mode === "enter" && phase === "execute" && !artifactExists(cwd, run, PHASE_ARTIFACTS.plan)) {
70
56
  missing.push(PHASE_ARTIFACTS.plan);
71
57
  }
72
58
 
73
- const flagshipPathProblem = target === "execute" ? flagshipPlanBlockReason(cwd, run, readArtifact(cwd, run, "plan") ?? "") : undefined;
59
+ const markerProblem = mode === "inspect" ? inspectMarkers(cwd, run, phase) : undefined;
60
+ const flagshipPathProblem = mode === "enter" && phase === "execute" ? flagshipPlanBlockReason(cwd, run, readArtifact(cwd, run, "plan") ?? "") : undefined;
61
+ const stepProblem = inspectStepState(cwd, run, phase);
62
+ const evidenceProblem = mode === "inspect" ? inspectEvidence(cwd, run, phase) : undefined;
63
+ const questionProblem = inspectOpenQuestions(run);
64
+
74
65
  if (flagshipPathProblem) {
75
66
  reasonParts.push(flagshipPathProblem);
76
67
  }
77
-
78
- const stepProblem = inspectStepState(cwd, run, target);
68
+ if (markerProblem) {
69
+ reasonParts.push(markerProblem);
70
+ }
79
71
  if (stepProblem) {
80
72
  reasonParts.push(stepProblem);
81
73
  }
82
-
83
- const questionProblem = inspectOpenQuestions(run);
74
+ if (evidenceProblem) {
75
+ reasonParts.push(evidenceProblem);
76
+ }
84
77
  if (questionProblem) {
85
78
  reasonParts.push(questionProblem);
86
79
  }
87
80
 
88
- missing.push(...missingEvidenceForPhase(cwd, run, target));
81
+ if (mode === "enter") {
82
+ missing.push(...missingEvidenceForPhase(cwd, run, phase));
89
83
 
90
- if (target === "ship" && !artifactExists(cwd, run, PHASE_ARTIFACTS.verify)) {
91
- missing.push(PHASE_ARTIFACTS.verify);
84
+ if (phase === "ship" && !artifactExists(cwd, run, PHASE_ARTIFACTS.verify)) {
85
+ missing.push(PHASE_ARTIFACTS.verify);
86
+ }
87
+ }
88
+
89
+ if (missing.length > 0) {
90
+ const uniqueMissing = [...new Set(missing)];
91
+ reasonParts.push(mode === "inspect" ? missingArtifactsReason(uniqueMissing) : missingForTargetReason(phase, uniqueMissing));
92
92
  }
93
93
 
94
- if (missing.length > 0) reasonParts.push(missingForTargetReason(target, [...new Set(missing)]));
94
+ return reasonParts;
95
+ }
96
+
97
+ function gateResult(reasonParts: string[]): GateResult {
95
98
  const reason = reasonParts.length > 0 ? reasonParts.join("; ") : undefined;
96
99
  return {
97
100
  passed: !reason,
@@ -100,6 +103,30 @@ export function canEnterPhase(cwd: string, run: ActiveRun, target: KdPhase): Gat
100
103
  };
101
104
  }
102
105
 
106
+ function unknownProductReason(): string {
107
+ return "不能离开 discuss:产品画像未知。下一步:根据需求或用户回答确认产品,然后执行 /kd-product <flagship|cosmic|xinghan|cangqiong|enterprise>;如果无法判断,先用 kd_question 只问一个最阻塞的产品确认问题。";
108
+ }
109
+
110
+ function hasRiskAssessment(cwd: string, run: ActiveRun): boolean {
111
+ const level = run.riskAssessment?.level;
112
+ if (!level) return false;
113
+ if (run.riskAssessment?.reason.trim()) return true;
114
+ return riskSectionHasContent(readArtifact(cwd, run, "verify") ?? "") || riskSectionHasContent(readArtifact(cwd, run, "ship") ?? "");
115
+ }
116
+
117
+ function unknownRiskReason(): string {
118
+ return "不能进入 ship:风险等级或风险原因未知。下一步:根据 VERIFY.md 和 SHIP.md 的残余风险执行 /kd-risk <low|medium|high> <原因>,或在风险章节写入真实风险说明后再刷新门禁。";
119
+ }
120
+
121
+ function riskSectionHasContent(content: string): boolean {
122
+ const match = content.match(/##\s*(残余风险|风险)\s*\r?\n([\s\S]*?)(?=\r?\n##\s+|$)/);
123
+ if (!match) return false;
124
+ return match[2]
125
+ .split(/\r?\n/)
126
+ .map((line) => line.trim())
127
+ .some((line) => line && !/^[-*]?\s*(无|未知|待确认|待补充|none|unknown)$/i.test(line));
128
+ }
129
+
103
130
  function inspectOpenQuestions(run: ActiveRun): string | undefined {
104
131
  const open = (run.questions ?? []).filter((question) => question.status === "open" && question.blocking);
105
132
  if (open.length === 0) return undefined;
@@ -172,7 +199,8 @@ function requiredEvidenceForPhase(cwd: string, run: ActiveRun, phase: KdPhase):
172
199
 
173
200
  function evidenceArtifactSatisfied(cwd: string, run: ActiveRun, artifact: string): boolean {
174
201
  if (artifact === SDK_SIGNATURE_EVIDENCE) return hasValidSdkSignatureEvidence(cwd, run);
175
- return existsSync(join(runRoot(cwd, run), artifact));
202
+ if (artifact === EVIDENCE_INDEX) return existsSync(join(runRoot(cwd, run), artifact));
203
+ return existsSync(join(runRoot(cwd, run), artifact)) && hasEvidenceEntry(cwd, run, artifact);
176
204
  }
177
205
 
178
206
  function missingArtifactsReason(artifacts: string[]): string {
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { ActiveRun } from "./types.ts";
4
4
  import { runRoot } from "./paths.ts";
5
+ import { hasEvidenceEntry } from "./evidence.ts";
5
6
 
6
7
  export interface PlanStep {
7
8
  id: string;
@@ -49,7 +50,7 @@ export function executionStepsBlockReason(cwd: string, run: ActiveRun, plan: str
49
50
  }
50
51
 
51
52
  const evidencePaths = [...line.matchAll(EVIDENCE_PATTERN)].map((match) => normalizeEvidencePath(match[0]));
52
- if (evidencePaths.length === 0 || !evidencePaths.some((path) => existsSync(join(runRoot(cwd, run), path)))) {
53
+ if (evidencePaths.length === 0 || !evidencePaths.some((path) => existsSync(join(runRoot(cwd, run), path)) && hasEvidenceEntry(cwd, run, path))) {
53
54
  missingEvidence.push(step.id);
54
55
  }
55
56
  }
@@ -3,6 +3,7 @@ import { isAbsolute, join, relative } from "node:path";
3
3
  import type { ActiveRun } from "./types.ts";
4
4
  import { runRoot } from "./paths.ts";
5
5
  import { isSourceLikePath } from "./path-policy.ts";
6
+ import { hasEvidenceEntry } from "./evidence.ts";
6
7
 
7
8
  export const SDK_SIGNATURE_EVIDENCE = "evidence/sdk-signature.md";
8
9
 
@@ -15,6 +16,7 @@ export function hasValidSdkSignatureEvidence(cwd: string, run: ActiveRun): boole
15
16
  if (!requiresSdkSignatureEvidence(run)) return true;
16
17
  const path = join(runRoot(cwd, run), SDK_SIGNATURE_EVIDENCE);
17
18
  if (!existsSync(path)) return false;
19
+ if (!hasEvidenceEntry(cwd, run, SDK_SIGNATURE_EVIDENCE)) return false;
18
20
 
19
21
  const content = readFileSync(path, "utf8");
20
22
  return /(退出码|Exit)\s*[::]\s*0/i.test(content) && /(来源|Sources?)\s*[::]/i.test(content);
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import type { ActiveRun, KdPhase, KdQuestion } from "./types.ts";
2
+ import type { ActiveRun, 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";
@@ -79,7 +79,6 @@ export function createActiveRun(cwd: string, goal: string, productInput?: string
79
79
  product: profile.product,
80
80
  version,
81
81
  profile,
82
- risk: "unknown",
83
82
  artifacts: {},
84
83
  questions: [],
85
84
  gate: {
@@ -143,6 +142,18 @@ export function updateProductProfile(cwd: string, run: ActiveRun, productInput:
143
142
  return run;
144
143
  }
145
144
 
145
+ export function updateRisk(cwd: string, run: ActiveRun, risk: KdRisk, reason: string, source: KdRiskSource = "manual"): ActiveRun {
146
+ run.riskAssessment = {
147
+ level: risk,
148
+ reason: reason.trim(),
149
+ source,
150
+ updatedAt: new Date().toISOString(),
151
+ };
152
+ run.gate = inspectGate(cwd, run);
153
+ writeActiveRun(cwd, run);
154
+ return run;
155
+ }
156
+
146
157
  export function ensurePhaseArtifact(cwd: string, run: ActiveRun, phase: KdPhase): string {
147
158
  const path = ensureArtifact(cwd, run, phase, defaultArtifactContent(phase));
148
159
  run.artifacts[phase] = PHASE_ARTIFACTS[phase];
@@ -3,6 +3,7 @@ import { isAbsolute, join, relative } from "node:path";
3
3
  import type { ActiveRun } from "./types.ts";
4
4
  import { runRoot } from "./paths.ts";
5
5
  import { isSourceLikePath } from "./path-policy.ts";
6
+ import { hasEvidenceEntry } from "./evidence.ts";
6
7
 
7
8
  export const TDD_RED_EVIDENCE = "evidence/tdd-red.md";
8
9
  export const TDD_GREEN_EVIDENCE = "evidence/tdd-green.md";
@@ -45,6 +46,7 @@ function validateTddEvidence(cwd: string, run: ActiveRun, kind: "red" | "green")
45
46
  const evidenceName = kind === "red" ? TDD_RED_EVIDENCE : TDD_GREEN_EVIDENCE;
46
47
  const evidencePath = join(runRoot(cwd, run), evidenceName);
47
48
  if (!existsSync(evidencePath)) return { valid: false, reason: `缺少 ${evidenceName}` };
49
+ if (!hasEvidenceEntry(cwd, run, evidenceName)) return { valid: false, reason: `${evidenceName} 未登记到 evidence/index.json` };
48
50
 
49
51
  const content = readFileSync(evidencePath, "utf8");
50
52
  const uncertainty = uncertaintyReason(content);
@@ -1,8 +1,16 @@
1
1
  import type { KdProduct, ProductProfile } from "../product/profile.ts";
2
2
 
3
3
  export type KdPhase = "discuss" | "spec" | "plan" | "execute" | "verify" | "ship";
4
- export type KdRisk = "unknown" | "low" | "medium" | "high";
4
+ export type KdRisk = "low" | "medium" | "high";
5
5
  export type KdRunStatus = "active" | "paused" | "done";
6
+ export type KdRiskSource = "manual" | "verify" | "ship";
7
+
8
+ export interface RiskAssessment {
9
+ level: KdRisk;
10
+ reason: string;
11
+ source: KdRiskSource;
12
+ updatedAt: string;
13
+ }
6
14
 
7
15
  export interface GateResult {
8
16
  passed: boolean;
@@ -34,7 +42,7 @@ export interface ActiveRun {
34
42
  product?: KdProduct;
35
43
  version?: string;
36
44
  profile?: ProductProfile;
37
- risk?: KdRisk;
45
+ riskAssessment?: RiskAssessment;
38
46
  artifacts: Record<string, string>;
39
47
  gate: GateResult;
40
48
  questions?: KdQuestion[];
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { ProductProfile } from "../product/profile.ts";
7
7
  import { readActiveRun } from "../harness/state.ts";
8
- import { runArtifactPath } from "../harness/paths.ts";
8
+ import { writeEvidenceFile } from "../harness/evidence.ts";
9
9
  import { searchKnowledge } from "../knowledge/search.ts";
10
10
  import { formatSearchResults } from "../knowledge/format.ts";
11
11
  import { resolveWorkspacePath } from "../platform/path.ts";
@@ -174,15 +174,15 @@ export function writeOfficialEvidence(cwd: string, evidenceFile: OfficialEvidenc
174
174
  const run = readActiveRun(cwd);
175
175
  if (!run) return undefined;
176
176
 
177
- const path = runArtifactPath(cwd, run, join("evidence", evidenceFile));
178
- mkdirSync(dirname(path), { recursive: true });
179
-
180
177
  const content =
181
178
  evidenceFile === "cosmic-metadata.json"
182
179
  ? formatJsonEvidence(evidenceFile, result)
183
180
  : `${formatCommandResult(result)}\nCaptured: ${new Date().toISOString()}\n`;
184
- writeFileSync(path, content.endsWith("\n") ? content : `${content}\n`, "utf8");
185
- return path;
181
+ return writeEvidenceFile(cwd, run, join("evidence", evidenceFile), content, {
182
+ kind: evidenceFile.replace(/\.(txt|json)$/i, ""),
183
+ command: result.command,
184
+ exitCode: result.exitCode,
185
+ });
186
186
  }
187
187
 
188
188
  function formatJsonEvidence(evidenceFile: OfficialEvidenceFile, result: CommandResult): string {