kcode-pi 0.1.24 → 0.1.27

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/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@
6
6
 
7
7
  - 暂无。
8
8
 
9
+ ## 0.1.27 - 2026-06-07
10
+
11
+ ### 修复
12
+
13
+ - 强化 active run / RUN.json 读取容错,过滤异常 `questions`、`artifacts`、`riskAssessment`、`gate` 字段,避免旧状态或坏状态触发门禁崩溃。
14
+ - Header 渲染门禁时增加兜底,门禁异常会显示为阻塞状态,不再导致 TUI 进程退出。
15
+ - 阶段产物存在但不可读或误建为目录时,现在按缺失处理,不再在读取 `CONTEXT.md`、`PLAN.md`、`EXECUTION.md` 等文件时抛出异常。
16
+
17
+ ### 验证
18
+
19
+ - `npm run smoke:harness` 通过,覆盖损坏 run state 和损坏 evidence index 的容错。
20
+
21
+ ## 0.1.26 - 2026-06-07
22
+
23
+ ### 修复
24
+
25
+ - 修复已有项目的 `evidence/index.json` 中存在异常 entry 时,Header 门禁渲染可能因读取 `path` 崩溃的问题。
26
+ - `readEvidenceIndex` 现在会过滤无效 evidence entry,`hasEvidenceEntry` 和 evidence 记录逻辑对坏数据保持容错。
27
+
28
+ ### 验证
29
+
30
+ - `npm run smoke:harness` 通过,覆盖损坏 evidence index 不再触发崩溃。
31
+
32
+ ## 0.1.25 - 2026-06-07
33
+
34
+ ### 修复
35
+
36
+ - 修复 `/kd-risk`、`/kd-product`、`/kd-answer`、`/kd-artifact` 等命令在解除门禁后不会自动尝试推进下一阶段的问题。
37
+ - 修复 `kd_question` 回答阻断问题后只刷新门禁、不尝试推进的问题。
38
+ - 修复 `kd_cosmic_config`、`kd_cosmic_metadata`、`kd_cosmic_api`、`kd_sdk_signature`、`kd_ksql_lint` 写入证据后不反馈自动推进结果的问题。
39
+ - 修复官方 evidence 只要文件存在就可能满足门禁的问题;现在要求 evidence index 中记录的退出码为 `0`。
40
+
41
+ ### 验证
42
+
43
+ - `npm run smoke:harness` 通过,覆盖阻断问题回答后的自动推进、风险更新后的自动推进尝试,以及失败 evidence 不能通过门禁。
44
+
9
45
  ## 0.1.24 - 2026-06-07
10
46
 
11
47
  ### 新增
@@ -4,6 +4,7 @@ import { formatStatus } from "../src/harness/format.ts";
4
4
  import { PHASE_ORDER, isKdPhase, type KdPhase } from "../src/harness/types.ts";
5
5
  import {
6
6
  advanceRun,
7
+ advanceRunIfReady,
7
8
  addQuestion,
8
9
  answerQuestion,
9
10
  createActiveRun,
@@ -109,6 +110,26 @@ function sendWorkflowPrompt(pi: ExtensionAPI, ctx: ExtensionContext, run: NonNul
109
110
  if (ctx.hasUI) ctx.ui.notify("KCode 工作流消息已排队。", "info");
110
111
  }
111
112
 
113
+ function autoAdvanceCommand(
114
+ pi: ExtensionAPI,
115
+ ctx: ExtensionContext,
116
+ run: NonNullable<ReturnType<typeof readActiveRun>>,
117
+ reason: string,
118
+ ): ReturnType<typeof advanceRunIfReady> {
119
+ const result = advanceRunIfReady(ctx.cwd, run);
120
+ if (result.advanced) {
121
+ if (ctx.hasUI) ctx.ui.notify(result.message, "info");
122
+ sendWorkflowPrompt(pi, ctx, result.run, `${reason}\n继续 KCode Harness run ${result.run.id}:${result.run.goal ?? "未知需求"}`);
123
+ } else if (!result.message.includes("最终阶段")) {
124
+ if (ctx.hasUI) ctx.ui.notify(`门禁仍阻塞:${result.message}`, "warning");
125
+ }
126
+ return result;
127
+ }
128
+
129
+ function autoAdvanceTool(cwd: string, run: NonNullable<ReturnType<typeof readActiveRun>>): ReturnType<typeof advanceRunIfReady> {
130
+ return advanceRunIfReady(cwd, run);
131
+ }
132
+
112
133
  function codeWriteBlockReason(cwd: string, path: string | undefined): string | undefined {
113
134
  if (!path || !isSourceLikePath(path)) return undefined;
114
135
 
@@ -188,9 +209,10 @@ const kdQuestionTool = defineTool({
188
209
  };
189
210
  }
190
211
  appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]);
212
+ const auto = autoAdvanceTool(ctx.cwd, run);
191
213
  return {
192
- content: [{ type: "text", text: `已记录 ${answered.id} 的答案,并刷新门禁。` }],
193
- details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate },
214
+ content: [{ type: "text", text: `已记录 ${answered.id} 的答案。${auto.advanced ? auto.message : `门禁仍阻塞:${auto.message}`}` }],
215
+ details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate, autoAdvance: auto },
194
216
  };
195
217
  }
196
218
 
@@ -240,9 +262,10 @@ const kdQuestionTool = defineTool({
240
262
  const answered = answerQuestion(ctx.cwd, run, question.id, interactiveAnswer);
241
263
  if (answered) {
242
264
  appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]);
265
+ const auto = autoAdvanceTool(ctx.cwd, run);
243
266
  return {
244
- content: [{ type: "text", text: `用户已回答 ${answered.id}:${answered.answer}` }],
245
- details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate, answered: true },
267
+ content: [{ type: "text", text: `用户已回答 ${answered.id}:${answered.answer}。${auto.advanced ? auto.message : `门禁仍阻塞:${auto.message}`}` }],
268
+ details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate, answered: true, autoAdvance: auto },
246
269
  };
247
270
  }
248
271
  }
@@ -427,6 +450,7 @@ export default function (pi: ExtensionAPI) {
427
450
 
428
451
  const updated = updateProductProfile(ctx.cwd, run, parsed.product, parsed.version);
429
452
  ctx.ui.notify(`产品画像:${updated.profile?.product}/${updated.profile?.techStack}/${updated.profile?.language}`, "info");
453
+ autoAdvanceCommand(pi, ctx, updated, "产品画像已更新。");
430
454
  },
431
455
  });
432
456
 
@@ -447,6 +471,7 @@ export default function (pi: ExtensionAPI) {
447
471
 
448
472
  const updated = updateRisk(ctx.cwd, run, risk.risk, risk.reason);
449
473
  ctx.ui.notify(`风险等级:${updated.riskAssessment?.level}(${updated.riskAssessment?.reason})`, "info");
474
+ autoAdvanceCommand(pi, ctx, updated, "风险评估已更新。");
450
475
  },
451
476
  });
452
477
 
@@ -498,6 +523,7 @@ export default function (pi: ExtensionAPI) {
498
523
  ? updatePhaseArtifact(ctx.cwd, run, parsed.phase, parsed.content)
499
524
  : ensurePhaseArtifact(ctx.cwd, run, parsed.phase);
500
525
  ctx.ui.notify(`阶段文档已就绪:${path}`, "info");
526
+ autoAdvanceCommand(pi, ctx, readActiveRun(ctx.cwd) ?? run, `${parsed.phase} 阶段文档已更新。`);
501
527
  },
502
528
  });
503
529
 
@@ -522,6 +548,7 @@ export default function (pi: ExtensionAPI) {
522
548
  }
523
549
  appendQuestionEventToArtifact(ctx.cwd, run, [`- 已回答 ${answered.id}:${answered.answer}`]);
524
550
  ctx.ui.notify(`已记录 ${answered.id} 的答案`, "info");
551
+ autoAdvanceCommand(pi, ctx, readActiveRun(ctx.cwd) ?? run, `${answered.id} 已回答。`);
525
552
  },
526
553
  });
527
554
  }
@@ -19,6 +19,19 @@ function formatGate(gate: GateResult | undefined): string {
19
19
  return gate.passed ? "门禁:通过" : "门禁:阻塞";
20
20
  }
21
21
 
22
+ function safeInspectGate(cwd: string, run: ActiveRun | undefined): GateResult | undefined {
23
+ if (!run) return undefined;
24
+ try {
25
+ return inspectGate(cwd, run);
26
+ } catch (error) {
27
+ return {
28
+ passed: false,
29
+ reason: error instanceof Error ? error.message : String(error),
30
+ checkedAt: new Date().toISOString(),
31
+ };
32
+ }
33
+ }
34
+
22
35
  function riskLevel(run: ActiveRun | undefined): string {
23
36
  return run?.riskAssessment?.level ?? "未知";
24
37
  }
@@ -54,7 +67,7 @@ export default function (pi: ExtensionAPI) {
54
67
  return {
55
68
  render(width: number): string[] {
56
69
  const run = readActiveRun(ctx.cwd);
57
- const gateState = run ? inspectGate(ctx.cwd, run) : undefined;
70
+ const gateState = safeInspectGate(ctx.cwd, run);
58
71
  const phase = formatPhase(run?.phase);
59
72
  const product = formatProduct(run);
60
73
  const gate = formatGate(gateState);
@@ -22,7 +22,7 @@ import {
22
22
  writeOfficialEvidence,
23
23
  } from "../src/official/kingdee-skills.ts";
24
24
  import { resolveWorkspacePath } from "../src/platform/path.ts";
25
- import { readActiveRun } from "../src/harness/state.ts";
25
+ import { advanceRunIfReady, readActiveRun } from "../src/harness/state.ts";
26
26
  import { writeEvidenceFile } from "../src/harness/evidence.ts";
27
27
  import { SDK_SIGNATURE_EVIDENCE } from "../src/harness/sdk-policy.ts";
28
28
 
@@ -97,12 +97,25 @@ async function runOrDryRun(
97
97
 
98
98
  const result = await runOfficialCommand(command);
99
99
  const evidencePath = evidenceFile ? writeOfficialEvidence(ctx.cwd, evidenceFile, result) : undefined;
100
+ const autoAdvance = evidencePath ? autoAdvanceAfterEvidence(ctx.cwd) : undefined;
100
101
  return {
101
- content: [{ type: "text" as const, text: formatCommandResult(result) }],
102
- details: { command: result.command, exitCode: result.exitCode, evidencePath },
102
+ content: [{ type: "text" as const, text: [formatCommandResult(result), evidencePath ? `已写入证据:${evidencePath}` : undefined, autoAdvance?.text].filter(Boolean).join("\n\n") }],
103
+ details: { command: result.command, exitCode: result.exitCode, evidencePath, autoAdvance: autoAdvance?.details },
103
104
  };
104
105
  }
105
106
 
107
+ function autoAdvanceAfterEvidence(cwd: string): { text: string; details: ReturnType<typeof advanceRunIfReady> } | undefined {
108
+ const run = readActiveRun(cwd);
109
+ if (!run) return undefined;
110
+
111
+ const result = advanceRunIfReady(cwd, run);
112
+ if (result.advanced) {
113
+ return { text: result.message, details: result };
114
+ }
115
+ if (result.message.includes("最终阶段")) return undefined;
116
+ return { text: `门禁仍阻塞:${result.message}`, details: result };
117
+ }
118
+
106
119
  const kdSearchTool = defineTool({
107
120
  name: "kd_search",
108
121
  label: "KD 搜索",
@@ -334,9 +347,10 @@ const kdSdkSignatureTool = defineTool({
334
347
  });
335
348
  const text = formatSdkSignatureResult(result);
336
349
  const evidencePath = result.exitCode === 0 ? writeSdkSignatureEvidence(ctx.cwd, text) : undefined;
350
+ const autoAdvance = evidencePath ? autoAdvanceAfterEvidence(ctx.cwd) : undefined;
337
351
  return {
338
- content: [{ type: "text", text: evidencePath ? `${text}\n\n已写入 SDK 签名证据:${evidencePath}` : text }],
339
- details: { product: profile.product, evidencePath, ...result },
352
+ content: [{ type: "text", text: [text, evidencePath ? `已写入 SDK 签名证据:${evidencePath}` : undefined, autoAdvance?.text].filter(Boolean).join("\n\n") }],
353
+ details: { product: profile.product, evidencePath, autoAdvance: autoAdvance?.details, ...result },
340
354
  };
341
355
  },
342
356
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.24",
3
+ "version": "0.1.27",
4
4
  "description": "面向金蝶开发的 Pi Coding Agent 启动器、工具包和 Harness 工作流",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import type { ActiveRun, KdPhase } from "./types.ts";
3
3
  import { PHASE_ARTIFACTS } from "./types.ts";
4
4
  import { runArtifactPath, runRoot } from "./paths.ts";
@@ -10,7 +10,11 @@ export function ensureRunDirectories(cwd: string, run: ActiveRun): void {
10
10
  }
11
11
 
12
12
  export function artifactExists(cwd: string, run: ActiveRun, artifactName: string): boolean {
13
- return existsSync(runArtifactPath(cwd, run, artifactName));
13
+ try {
14
+ return statSync(runArtifactPath(cwd, run, artifactName)).isFile();
15
+ } catch {
16
+ return false;
17
+ }
14
18
  }
15
19
 
16
20
  export function phaseArtifactPath(cwd: string, run: ActiveRun, phase: KdPhase): string {
@@ -19,7 +23,11 @@ export function phaseArtifactPath(cwd: string, run: ActiveRun, phase: KdPhase):
19
23
 
20
24
  export function readArtifact(cwd: string, run: ActiveRun, phase: KdPhase): string | undefined {
21
25
  const path = phaseArtifactPath(cwd, run, phase);
22
- return existsSync(path) ? readFileSync(path, "utf8") : undefined;
26
+ try {
27
+ return existsSync(path) ? readFileSync(path, "utf8") : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
23
31
  }
24
32
 
25
33
  export function writeArtifact(cwd: string, run: ActiveRun, phase: KdPhase, content: string): string {
@@ -28,8 +28,9 @@ export function readEvidenceIndex(cwd: string, run: ActiveRun): EvidenceIndex {
28
28
  const path = evidenceIndexPath(cwd, run);
29
29
  if (!existsSync(path)) return { version: 1, entries: [] };
30
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: [] };
31
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<EvidenceIndex>;
32
+ if (parsed.version !== 1 || !Array.isArray(parsed.entries)) return { version: 1, entries: [] };
33
+ return { version: 1, entries: parsed.entries.filter(isEvidenceEntry) };
33
34
  } catch {
34
35
  return { version: 1, entries: [] };
35
36
  }
@@ -37,6 +38,7 @@ export function readEvidenceIndex(cwd: string, run: ActiveRun): EvidenceIndex {
37
38
 
38
39
  export function hasEvidenceEntry(cwd: string, run: ActiveRun, path: string): boolean {
39
40
  const normalized = normalizeEvidencePath(path);
41
+ if (!normalized) return false;
40
42
  return readEvidenceIndex(cwd, run).entries.some((entry) => normalizeEvidencePath(entry.path) === normalized);
41
43
  }
42
44
 
@@ -48,7 +50,7 @@ export function writeEvidenceFile(
48
50
  options: { kind?: string; command?: string; exitCode?: number } = {},
49
51
  ): string {
50
52
  const normalized = normalizeEvidencePath(path);
51
- if (!normalized.startsWith("evidence/") || normalized === EVIDENCE_INDEX) {
53
+ if (!normalized || !normalized.startsWith("evidence/") || normalized === EVIDENCE_INDEX) {
52
54
  throw new Error(`非法 evidence 路径:${path}`);
53
55
  }
54
56
 
@@ -66,6 +68,7 @@ export function recordEvidence(
66
68
  options: { kind?: string; command?: string; exitCode?: number } = {},
67
69
  ): void {
68
70
  const normalized = normalizeEvidencePath(path);
71
+ if (!normalized) return;
69
72
  const absolutePath = join(runRoot(cwd, run), normalized);
70
73
  if (!existsSync(absolutePath)) return;
71
74
 
@@ -96,11 +99,19 @@ export function recordEvidence(
96
99
  writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8");
97
100
  }
98
101
 
102
+ function isEvidenceEntry(value: unknown): value is EvidenceEntry {
103
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
104
+ const entry = value as Partial<EvidenceEntry>;
105
+ return typeof entry.path === "string" && entry.path.trim().length > 0;
106
+ }
107
+
99
108
  function inferKind(path: string): string {
100
109
  const name = path.split("/").at(-1) ?? path;
101
110
  return name.replace(/\.(md|txt|json)$/i, "");
102
111
  }
103
112
 
104
- function normalizeEvidencePath(path: string): string {
105
- return path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
113
+ function normalizeEvidencePath(path: unknown): string | undefined {
114
+ if (typeof path !== "string") return undefined;
115
+ const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
116
+ return normalized.trim() || undefined;
106
117
  }
@@ -9,7 +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
+ import { EVIDENCE_INDEX, readEvidenceIndex } from "./evidence.ts";
13
13
  import {
14
14
  missingArtifactsReason,
15
15
  missingEvidenceReason,
@@ -129,7 +129,7 @@ function productImplementationDeclaration(cwd: string, run: ActiveRun): boolean
129
129
  function hasRiskAssessment(cwd: string, run: ActiveRun): boolean {
130
130
  const level = run.riskAssessment?.level;
131
131
  if (!level) return false;
132
- if (run.riskAssessment?.reason.trim()) return true;
132
+ if (typeof run.riskAssessment?.reason === "string" && run.riskAssessment.reason.trim()) return true;
133
133
  return riskSectionHasContent(readArtifact(cwd, run, "verify") ?? "") || riskSectionHasContent(readArtifact(cwd, run, "ship") ?? "");
134
134
  }
135
135
 
@@ -143,7 +143,7 @@ function riskSectionHasContent(content: string): boolean {
143
143
  }
144
144
 
145
145
  function inspectOpenQuestions(run: ActiveRun): string | undefined {
146
- const open = (run.questions ?? []).filter((question) => question.status === "open" && question.blocking);
146
+ const open = Array.isArray(run.questions) ? run.questions.filter((question) => question.status === "open" && question.blocking) : [];
147
147
  if (open.length === 0) return undefined;
148
148
  return openQuestionsReason(open);
149
149
  }
@@ -212,7 +212,13 @@ function requiredEvidenceForPhase(cwd: string, run: ActiveRun, phase: KdPhase):
212
212
  function evidenceArtifactSatisfied(cwd: string, run: ActiveRun, artifact: string): boolean {
213
213
  if (artifact === SDK_SIGNATURE_EVIDENCE) return hasValidSdkSignatureEvidence(cwd, run);
214
214
  if (artifact === EVIDENCE_INDEX) return existsSync(join(runRoot(cwd, run), artifact));
215
- return existsSync(join(runRoot(cwd, run), artifact)) && hasEvidenceEntry(cwd, run, artifact);
215
+ return existsSync(join(runRoot(cwd, run), artifact)) && hasSuccessfulEvidenceEntry(cwd, run, artifact);
216
+ }
217
+
218
+ function hasSuccessfulEvidenceEntry(cwd: string, run: ActiveRun, artifact: string): boolean {
219
+ const normalized = artifact.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
220
+ const entry = readEvidenceIndex(cwd, run).entries.find((item) => typeof item.path === "string" && item.path.replace(/\\/g, "/") === normalized);
221
+ return Boolean(entry && entry.exitCode === 0);
216
222
  }
217
223
 
218
224
  function planHasMetadataRequirement(cwd: string, run: ActiveRun): boolean {
@@ -100,7 +100,7 @@ export function addQuestion(
100
100
  run: ActiveRun,
101
101
  input: { question: string; reason?: string; choices?: string[]; blocking?: boolean },
102
102
  ): KdQuestion {
103
- const existing = run.questions ?? [];
103
+ const existing = Array.isArray(run.questions) ? run.questions : [];
104
104
  const question: KdQuestion = {
105
105
  id: createQuestionId(existing.length + 1),
106
106
  phase: run.phase,
@@ -118,7 +118,7 @@ export function addQuestion(
118
118
  }
119
119
 
120
120
  export function answerQuestion(cwd: string, run: ActiveRun, id: string, answer: string): KdQuestion | undefined {
121
- const question = (run.questions ?? []).find((item) => item.id === id);
121
+ const question = (Array.isArray(run.questions) ? run.questions : []).find((item) => item.id === id);
122
122
  if (!question) return undefined;
123
123
  question.status = "answered";
124
124
  question.answer = answer.trim();
@@ -129,7 +129,7 @@ export function answerQuestion(cwd: string, run: ActiveRun, id: string, answer:
129
129
  }
130
130
 
131
131
  export function openBlockingQuestions(run: ActiveRun): KdQuestion[] {
132
- return (run.questions ?? []).filter((question) => question.status === "open" && question.blocking);
132
+ return (Array.isArray(run.questions) ? run.questions : []).filter((question) => question.status === "open" && question.blocking);
133
133
  }
134
134
 
135
135
  export function updateProductProfile(cwd: string, run: ActiveRun, productInput: string, version?: string): ActiveRun {
@@ -189,6 +189,21 @@ export function advanceRun(cwd: string, run: ActiveRun, requestedPhase?: KdPhase
189
189
  return { run, message: `已推进 Kingdee run 到阶段:${target}` };
190
190
  }
191
191
 
192
+ export function advanceRunIfReady(cwd: string, run: ActiveRun): { run: ActiveRun; advanced: boolean; message: string } {
193
+ const current = readRun(cwd, run.id) ?? run;
194
+ const target = nextPhase(current.phase);
195
+ if (!target) {
196
+ return { run: current, advanced: false, message: `Run 已处于最终阶段:${current.phase}` };
197
+ }
198
+
199
+ const result = advanceRun(cwd, current, target);
200
+ return {
201
+ run: result.run,
202
+ advanced: result.run.phase === target,
203
+ message: result.message,
204
+ };
205
+ }
206
+
192
207
  export function refreshGate(cwd: string, run: ActiveRun): ActiveRun {
193
208
  run.gate = inspectGate(cwd, run);
194
209
  writeActiveRun(cwd, run);
@@ -202,19 +217,75 @@ function writeRunState(cwd: string, run: ActiveRun): void {
202
217
  }
203
218
 
204
219
  function hydrateRun(parsed: ActiveRun): ActiveRun | undefined {
205
- if (!parsed.id || !isKdPhase(parsed.phase)) return undefined;
206
- parsed.artifacts ??= {};
207
- parsed.questions ??= [];
208
- parsed.status ??= "active";
209
- parsed.createdAt ??= parsed.updatedAt;
210
- parsed.updatedAt ??= parsed.createdAt;
220
+ if (!parsed || typeof parsed !== "object") return undefined;
221
+ if (typeof parsed.id !== "string" || !parsed.id.trim()) return undefined;
222
+ if (typeof parsed.phase !== "string" || !isKdPhase(parsed.phase)) return undefined;
223
+ parsed.id = parsed.id.trim();
224
+ parsed.artifacts = isPlainObject(parsed.artifacts) ? sanitizeArtifacts(parsed.artifacts) : {};
225
+ parsed.questions = Array.isArray(parsed.questions) ? parsed.questions.map(sanitizeQuestion).filter((question): question is KdQuestion => Boolean(question)) : [];
226
+ parsed.status = parsed.status === "paused" || parsed.status === "done" ? parsed.status : "active";
227
+ parsed.goal = typeof parsed.goal === "string" ? parsed.goal : undefined;
228
+ parsed.version = typeof parsed.version === "string" ? parsed.version : undefined;
229
+ parsed.createdAt = typeof parsed.createdAt === "string" ? parsed.createdAt : typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
230
+ parsed.updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : parsed.createdAt;
211
231
  const legacyEdition = (parsed as ActiveRun & { edition?: string }).edition;
212
- parsed.profile = parsed.profile ?? profileForProduct(parsed.product ?? resolveProductProfile(legacyEdition).product);
232
+ const product = typeof parsed.profile?.product === "string" ? parsed.profile.product : typeof parsed.product === "string" ? parsed.product : resolveProductProfile(typeof legacyEdition === "string" ? legacyEdition : undefined).product;
233
+ parsed.profile = profileForProduct(product);
213
234
  parsed.product = parsed.profile.product;
214
- parsed.gate ??= { passed: false, checkedAt: new Date().toISOString() };
235
+ parsed.riskAssessment = sanitizeRiskAssessment(parsed.riskAssessment);
236
+ parsed.gate = sanitizeGate(parsed.gate);
215
237
  return parsed;
216
238
  }
217
239
 
240
+ function sanitizeArtifacts(value: Record<string, unknown>): Record<string, string> {
241
+ return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string"));
242
+ }
243
+
244
+ function sanitizeQuestion(value: unknown): KdQuestion | undefined {
245
+ if (!isPlainObject(value)) return undefined;
246
+ const phase = typeof value.phase === "string" && isKdPhase(value.phase) ? value.phase : undefined;
247
+ const question = typeof value.question === "string" ? value.question.trim() : "";
248
+ if (!phase || !question) return undefined;
249
+ const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : createQuestionId(1);
250
+ return {
251
+ id,
252
+ phase,
253
+ question,
254
+ reason: typeof value.reason === "string" && value.reason.trim() ? value.reason.trim() : undefined,
255
+ choices: Array.isArray(value.choices) ? value.choices.filter((choice): choice is string => typeof choice === "string" && Boolean(choice.trim())).map((choice) => choice.trim()) : undefined,
256
+ blocking: value.blocking !== false,
257
+ status: value.status === "answered" ? "answered" : "open",
258
+ answer: typeof value.answer === "string" ? value.answer : undefined,
259
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
260
+ answeredAt: typeof value.answeredAt === "string" ? value.answeredAt : undefined,
261
+ };
262
+ }
263
+
264
+ function sanitizeRiskAssessment(value: unknown): ActiveRun["riskAssessment"] {
265
+ if (!isPlainObject(value)) return undefined;
266
+ const level = value.level;
267
+ if (level !== "low" && level !== "medium" && level !== "high") return undefined;
268
+ return {
269
+ level,
270
+ reason: typeof value.reason === "string" ? value.reason : "",
271
+ source: value.source === "verify" || value.source === "ship" ? value.source : "manual",
272
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString(),
273
+ };
274
+ }
275
+
276
+ function sanitizeGate(value: unknown): ActiveRun["gate"] {
277
+ if (!isPlainObject(value)) return { passed: false, checkedAt: new Date().toISOString() };
278
+ return {
279
+ passed: value.passed === true,
280
+ reason: typeof value.reason === "string" ? value.reason : undefined,
281
+ checkedAt: typeof value.checkedAt === "string" ? value.checkedAt : new Date().toISOString(),
282
+ };
283
+ }
284
+
285
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
286
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
287
+ }
288
+
218
289
  function createRunId(goal: string): string {
219
290
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
220
291
  const slug = goal