kcode-pi 0.1.8 → 0.1.9

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
@@ -274,6 +274,9 @@ kcode start --provider openai --model gpt-4o
274
274
  /kd-start [--product product] [--version version] <goal>
275
275
  /kd-product <product> [--version version]
276
276
  /kd-status
277
+ /kd-runs
278
+ /kd-switch <run-id>
279
+ /kd-finish
277
280
  /kd-gate
278
281
  /kd-advance [phase]
279
282
  /kd-artifact [phase] [content]
@@ -310,12 +313,30 @@ discuss -> spec -> plan -> execute -> verify -> ship
310
313
 
311
314
  KCode 会把金蝶开发需求纳入 Harness 工作流。你可以直接输入自然语言需求;如果当前项目还没有 active run,KCode 会自动创建 run 并进入 `discuss` 阶段,而不是直接生成代码。
312
315
 
316
+ 一个功能点对应一个 run。每个 run 都有独立阶段、计划、执行记录和证据;做另一个功能点时,不要复用上一个功能点的阶段。
317
+
318
+ ```text
319
+ /kd-start 实现采购订单保存校验
320
+ /kd-finish
321
+ /kd-start 实现采购订单列表字段展示
322
+ ```
323
+
324
+ 查看和切换当前项目里的功能点 run:
325
+
326
+ ```text
327
+ /kd-runs
328
+ /kd-switch <run-id>
329
+ ```
330
+
331
+ 如果当前 active run 处于 `execute` 或 `verify`,但你提出的是另一个需求,KCode 会要求先创建新 run 或切换 run,避免把新需求塞进旧功能点的 `PLAN.md` 和阶段门禁。
332
+
313
333
  每次进入 Harness 时,KCode 会附带 `.pi/kd/PROJECT_CONTEXT.md`。这份上下文不会替代计划阶段的实际文件检查,但会让 Agent 在暂停、恢复、重新打开终端后仍知道当前项目的源码根、模块线索和禁止猜路径规则。
314
334
 
315
335
  每个需求 run 的设计和执行文档会落到本地:
316
336
 
317
337
  ```text
318
338
  .pi/kd/active-run.json
339
+ .pi/kd/runs/<run-id>/RUN.json
319
340
  .pi/kd/runs/<run-id>/CONTEXT.md
320
341
  .pi/kd/runs/<run-id>/SPEC.md
321
342
  .pi/kd/runs/<run-id>/PLAN.md
@@ -325,7 +346,7 @@ KCode 会把金蝶开发需求纳入 Harness 工作流。你可以直接输入
325
346
  .pi/kd/runs/<run-id>/evidence/
326
347
  ```
327
348
 
328
- 下次重新 `kcode start` 时,KCode 会读取当前项目的 active run、项目常驻上下文和已生成的阶段文档,再继续当前阶段。
349
+ 下次重新 `kcode start` 时,KCode 会读取当前项目的 active run、项目常驻上下文和已生成的阶段文档,再继续当前功能点的当前阶段。已完成或暂停的功能点仍保留在 `.pi/kd/runs/<run-id>/`,可用 `/kd-runs` 查看,用 `/kd-switch <run-id>` 切回。
329
350
 
330
351
  阶段含义:
331
352
 
@@ -388,7 +409,17 @@ ship 汇总变更、验证证据、风险和后续事项
388
409
 
389
410
  如果 LLM 跳过步骤、没有记录 evidence,或只是口头声明完成,KCode 不允许进入 `verify`。
390
411
 
391
- 结构化问题也会进入门禁。Agent 需要确认产品、目标单据、插件位置、高风险 SQL 或方案选择时,会使用 `kd_question` 记录问题;未回答的阻断问题会阻止进入下一阶段。用户回复后,Agent 会记录答案;也可以手动执行:
412
+ 结构化问题也会进入门禁。Agent 需要确认产品、目标单据、插件位置、高风险 SQL 或方案选择时,会使用 `kd_question` 记录问题;未回答的阻断问题会阻止进入下一阶段。
413
+
414
+ `kd_question` 一次只允许问一个当前最阻塞的问题,不能把多个问题打包成编号清单。比如应先问:
415
+
416
+ ```text
417
+ 采购入库单 Form ID 是否为 pur_receivebill?
418
+ ```
419
+
420
+ 得到回答后,再继续问触发时机、库存条件、弹窗内容或插件位置。
421
+
422
+ 在 `kcode start` 的交互式 TUI 中,`kd_question` 会弹出 KCode Question 选择/输入对话,并在用户选择或输入后自动记录答案。非交互模式或 UI 不可用时,问题会显示在对话里,用户直接回复;Agent 读取回复后再记录答案。也可以手动执行:
392
423
 
393
424
  ```text
394
425
  /kd-answer Q-001 采购订单
@@ -399,6 +430,7 @@ ship 汇总变更、验证证据、风险和后续事项
399
430
  ```text
400
431
  .pi/kd/active-run.json
401
432
  .pi/kd/runs/<run-id>/
433
+ .pi/kd/runs/<run-id>/RUN.json
402
434
  ```
403
435
 
404
436
  ## 内置金蝶工具
@@ -1,5 +1,5 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { formatStatus } from "../src/harness/format.ts";
4
4
  import { PHASE_ORDER, isKdPhase, type KdPhase } from "../src/harness/types.ts";
5
5
  import {
@@ -8,8 +8,11 @@ import {
8
8
  answerQuestion,
9
9
  createActiveRun,
10
10
  ensurePhaseArtifact,
11
+ finishActiveRun,
12
+ listRuns,
11
13
  readActiveRun,
12
14
  refreshGate,
15
+ switchActiveRun,
13
16
  updateProductProfile,
14
17
  updatePhaseArtifact,
15
18
  } from "../src/harness/state.ts";
@@ -105,6 +108,9 @@ function workflowPromptForRun(cwd: string, run: NonNullable<ReturnType<typeof re
105
108
  "KCode 本次工作流本地文档:",
106
109
  memory,
107
110
  "",
111
+ `当前 active run 只属于这个功能点:${run.goal ?? run.id}。如果用户提出的是另一个功能点或无关新需求,必须停止沿用当前阶段,要求用户运行 /kd-start <新需求> 创建新 run,或 /kd-switch <run-id> 切换已有 run。`,
112
+ "需要用户确认时,kd_question 一次只能问一个当前最阻塞的问题;不要把 FormId、触发时机、库存条件、弹窗内容、插件位置等打包成清单。选项最多 3 个;交互模式下会弹出选择/输入对话并自动记录答案。",
113
+ "",
108
114
  phaseGuidance[run.phase],
109
115
  "必须先理解当前业务项目已有目录、模块、包名、基类和本地封装,再决定文件位置和实现方式。",
110
116
  "路径规则:在 Windows 工作区内,优先使用项目相对路径;如需绝对路径必须使用 `D:\\...` 这类 Windows 路径,禁止把路径改写成 `/mnt/d/...`、`/d/...` 等 WSL/MSYS 风格路径。",
@@ -164,14 +170,14 @@ const kdQuestionTool = defineTool({
164
170
  name: "kd_question",
165
171
  label: "KD Question",
166
172
  description:
167
- "Create, answer, or list structured Kingdee Harness questions. Open blocking questions stop phase advancement until answered.",
173
+ "Create, answer, or list structured Kingdee Harness questions. Ask exactly one short blocking question at a time; do not batch a checklist.",
168
174
  parameters: Type.Object({
169
175
  action: Type.Optional(Type.String({ description: "ask, answer, or list. Defaults to ask." })),
170
176
  id: Type.Optional(Type.String({ description: "Question id when action=answer, for example Q-001." })),
171
- question: Type.Optional(Type.String({ description: "Question to ask when action=ask." })),
177
+ question: Type.Optional(Type.String({ description: "One short question to ask when action=ask. No numbered lists or multiple questions." })),
172
178
  answer: Type.Optional(Type.String({ description: "User answer when action=answer." })),
173
179
  reason: Type.Optional(Type.String({ description: "Why this question blocks or matters." })),
174
- choices: Type.Optional(Type.Array(Type.String(), { description: "Optional concrete choices for the user." })),
180
+ choices: Type.Optional(Type.Array(Type.String(), { description: "Optional concrete choices for the user, maximum 3 short labels." })),
175
181
  blocking: Type.Optional(Type.Boolean({ description: "Whether the question blocks phase advancement. Defaults to true." })),
176
182
  }),
177
183
 
@@ -224,6 +230,26 @@ const kdQuestionTool = defineTool({
224
230
  details: { error: "missing-question" },
225
231
  };
226
232
  }
233
+ const batchProblem = questionBatchProblem(params.question, params.choices);
234
+ if (batchProblem) {
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: [
240
+ batchProblem,
241
+ "",
242
+ "kd_question 一次只登记一个当前最阻塞的问题。",
243
+ "请先问第一个必须确认的问题,例如:",
244
+ "kd_question action=ask question=\"采购入库单 Form ID 是否为 pur_receivebill?\" choices=[\"是\", \"不是\"]",
245
+ "",
246
+ "注意:Pi 当前不会弹出表单;问题会显示在对话里,用户在对话中回答后再用 kd_question action=answer 记录。",
247
+ ].join("\n"),
248
+ },
249
+ ],
250
+ details: { error: "batched-question" },
251
+ };
252
+ }
227
253
 
228
254
  const question = addQuestion(ctx.cwd, run, {
229
255
  question: params.question,
@@ -232,8 +258,20 @@ const kdQuestionTool = defineTool({
232
258
  blocking: params.blocking,
233
259
  });
234
260
  appendQuestionEventToArtifact(ctx.cwd, run, formatQuestionArtifactLines(question));
235
- const text = formatQuestionCard(question);
236
- return { content: [{ type: "text", text }], details: { question, gate: readActiveRun(ctx.cwd)?.gate } };
261
+ const interactiveAnswer = await askQuestionInteractively(ctx, question.question, question.choices);
262
+ if (interactiveAnswer) {
263
+ const answered = answerQuestion(ctx.cwd, run, question.id, interactiveAnswer);
264
+ if (answered) {
265
+ appendQuestionEventToArtifact(ctx.cwd, run, [`- Answered ${answered.id}: ${answered.answer}`]);
266
+ return {
267
+ content: [{ type: "text", text: `User answered ${answered.id}: ${answered.answer}` }],
268
+ details: { question: answered, gate: readActiveRun(ctx.cwd)?.gate, answered: true },
269
+ };
270
+ }
271
+ }
272
+
273
+ const text = `${formatQuestionCard(question)}\n\nInteractive answer was not provided; keep this question open until the user answers.`;
274
+ return { content: [{ type: "text", text }], details: { question, gate: readActiveRun(ctx.cwd)?.gate, answered: false } };
237
275
  },
238
276
  });
239
277
 
@@ -286,6 +324,14 @@ export default function (pi: ExtensionAPI) {
286
324
  },
287
325
  });
288
326
 
327
+ pi.registerCommand("kd-runs", {
328
+ description: "List Kingdee harness runs for this project",
329
+ handler: async (_args, ctx) => {
330
+ const active = readActiveRun(ctx.cwd);
331
+ ctx.ui.notify(formatRuns(listRuns(ctx.cwd), active?.id), "info");
332
+ },
333
+ });
334
+
289
335
  pi.registerCommand("kd-gate", {
290
336
  description: "Refresh and show the active Kingdee harness gate",
291
337
  handler: async (_args, ctx) => {
@@ -314,6 +360,39 @@ export default function (pi: ExtensionAPI) {
314
360
  },
315
361
  });
316
362
 
363
+ pi.registerCommand("kd-switch", {
364
+ description: "Switch active Kingdee harness run: /kd-switch <run-id>",
365
+ handler: async (args, ctx) => {
366
+ const id = args.trim();
367
+ if (!id) {
368
+ ctx.ui.notify("Usage: /kd-switch <run-id>", "error");
369
+ return;
370
+ }
371
+
372
+ const run = switchActiveRun(ctx.cwd, id);
373
+ if (!run) {
374
+ ctx.ui.notify(`Kingdee harness run not found: ${id}. Use /kd-runs to list runs.`, "error");
375
+ return;
376
+ }
377
+
378
+ ctx.ui.notify(`Switched active Kingdee harness run: ${run.id} (${run.phase})`, "info");
379
+ },
380
+ });
381
+
382
+ pi.registerCommand("kd-finish", {
383
+ description: "Mark the active Kingdee harness run as done and clear active run",
384
+ handler: async (_args, ctx) => {
385
+ const run = requireRun(ctx.cwd);
386
+ if (!run) {
387
+ ctx.ui.notify("No active Kingdee harness run. Use /kd-start <goal>.", "error");
388
+ return;
389
+ }
390
+
391
+ const finished = finishActiveRun(ctx.cwd, run);
392
+ ctx.ui.notify(`Finished Kingdee harness run: ${finished.id}. Start the next feature with /kd-start <goal>.`, "info");
393
+ },
394
+ });
395
+
317
396
  pi.registerCommand("kd-product", {
318
397
  description: "Set the active Kingdee product profile: /kd-product <flagship|cosmic|xinghan|cangqiong|enterprise> [--version version]",
319
398
  handler: async (args, ctx) => {
@@ -411,6 +490,20 @@ function formatQuestions(run: NonNullable<ReturnType<typeof readActiveRun>>): st
411
490
  return questions.map(formatQuestionCard).join("\n\n");
412
491
  }
413
492
 
493
+ function formatRuns(runs: NonNullable<ReturnType<typeof readActiveRun>>[], activeId?: string): string {
494
+ if (runs.length === 0) return "No Kingdee harness runs in this project. Start one with /kd-start <goal>.";
495
+ return runs
496
+ .map((run) =>
497
+ [
498
+ `${run.id === activeId ? "*" : "-"} ${run.id}`,
499
+ ` Goal: ${run.goal ?? "unknown"}`,
500
+ ` Status: ${run.status ?? "active"} | Phase: ${run.phase} | Product: ${run.profile?.product ?? run.product ?? "unknown"}`,
501
+ ` Updated: ${run.updatedAt ?? "unknown"}`,
502
+ ].join("\n"),
503
+ )
504
+ .join("\n\n");
505
+ }
506
+
414
507
  function formatQuestionCard(question: NonNullable<NonNullable<ReturnType<typeof readActiveRun>>["questions"]>[number]): string {
415
508
  const lines = [
416
509
  `Question ${question.id} [${question.status}${question.blocking ? ", blocking" : ""}]`,
@@ -419,11 +512,49 @@ function formatQuestionCard(question: NonNullable<NonNullable<ReturnType<typeof
419
512
  question.reason ? `Reason: ${question.reason}` : undefined,
420
513
  question.choices?.length ? `Choices: ${question.choices.join(" | ")}` : undefined,
421
514
  question.answer ? `Answer: ${question.answer}` : undefined,
422
- question.status === "open" ? `Reply with the answer, then record it using kd_question action=answer id=${question.id} answer=<answer>.` : undefined,
515
+ question.status === "open"
516
+ ? `Answer the dialog if available, or reply in chat and record it using kd_question action=answer id=${question.id} answer=<answer>.`
517
+ : undefined,
423
518
  ];
424
519
  return lines.filter(Boolean).join("\n");
425
520
  }
426
521
 
522
+ async function askQuestionInteractively(
523
+ ctx: ExtensionContext,
524
+ question: string,
525
+ choices: string[] | undefined,
526
+ ): Promise<string | undefined> {
527
+ if (!ctx.hasUI) return undefined;
528
+
529
+ const normalizedChoices = choices?.map((choice) => choice.trim()).filter(Boolean) ?? [];
530
+ try {
531
+ if (normalizedChoices.length === 0) {
532
+ return (await ctx.ui.input("KCode Question", question))?.trim() || undefined;
533
+ }
534
+
535
+ const customChoice = "其他,自定义输入";
536
+ const selected = await ctx.ui.select("KCode Question", [...normalizedChoices, customChoice]);
537
+ if (!selected) return undefined;
538
+ if (selected !== customChoice) return selected;
539
+ return (await ctx.ui.input("KCode Question", question))?.trim() || undefined;
540
+ } catch {
541
+ return undefined;
542
+ }
543
+ }
544
+
545
+ function questionBatchProblem(question: string, choices?: string[]): string | undefined {
546
+ const text = question.trim();
547
+ const numberedItems = text.split(/\r?\n/).filter((line) => /^\s*(\d+[\.\)、)]|[-*]\s+)/.test(line)).length;
548
+ if (numberedItems >= 2) return "kd_question rejected a batched checklist question.";
549
+ if ((text.match(/[??]/g) ?? []).length > 1) return "kd_question rejected multiple questions in one prompt.";
550
+ if (text.length > 220) return "kd_question rejected an overlong question. Ask only the smallest blocking question first.";
551
+ if ((choices?.length ?? 0) > 3) return "kd_question rejected too many choices. Provide at most 3 concise choices.";
552
+ if (choices?.some((choice) => choice.length > 40 || /[\r\n??]/.test(choice))) {
553
+ return "kd_question rejected complex choices. Choices must be short labels, not nested questions.";
554
+ }
555
+ return undefined;
556
+ }
557
+
427
558
  function formatQuestionArtifactLines(question: NonNullable<NonNullable<ReturnType<typeof readActiveRun>>["questions"]>[number]): string[] {
428
559
  return [
429
560
  `- ${question.id} [${question.blocking ? "blocking" : "non-blocking"}] ${question.question}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Kingdee-specific package and harness for Pi Coding Agent",
5
5
  "type": "module",
6
6
  "private": false,
@@ -20,5 +20,6 @@ Rules:
20
20
  - Do not propose implementation before the context is clear.
21
21
  - Do not invent SDK APIs, table names, or lifecycle methods.
22
22
  - If the product/version, tech stack, or target object is unknown, mark it as an open question.
23
+ - Ask unresolved questions one at a time. When using `kd_question`, ask only the single most blocking question and use at most 3 short choices; never submit a numbered checklist of questions.
23
24
  - Never apply enterprise C# rules to Java products, or Java/Cosmic plugin rules to enterprise C#.
24
25
  - Keep context concise; detailed design belongs in `SPEC.md`.
@@ -20,5 +20,4 @@ Rules:
20
20
  - Separate confirmed facts from assumptions.
21
21
  - Mark every API/table assumption as requiring lookup with `kd_search` or `kd_table`.
22
22
  - Do not edit product code in this phase.
23
- - If acceptance criteria are vague, ask targeted questions or state explicit assumptions.
24
-
23
+ - If acceptance criteria are vague, ask targeted questions one at a time or state explicit assumptions. When using `kd_question`, ask only the single most blocking question and use at most 3 short choices; never submit a numbered checklist of questions.
@@ -15,6 +15,8 @@ export function formatStatus(cwd: string, run: ActiveRun | undefined): string {
15
15
 
16
16
  return [
17
17
  `Run: ${refreshed.id}`,
18
+ refreshed.goal ? `Goal: ${refreshed.goal}` : undefined,
19
+ `Status: ${refreshed.status ?? "active"}`,
18
20
  `Phase: ${refreshed.phase}`,
19
21
  `Next: ${next}`,
20
22
  `Product: ${formatProductProfile(refreshed.profile)}`,
@@ -17,7 +17,11 @@ export function runRoot(cwd: string, run: ActiveRun): string {
17
17
  return join(runsDir(cwd), run.id);
18
18
  }
19
19
 
20
+ export function runStatePath(cwd: string, runOrId: ActiveRun | string): string {
21
+ const id = typeof runOrId === "string" ? runOrId : runOrId.id;
22
+ return join(runsDir(cwd), id, "RUN.json");
23
+ }
24
+
20
25
  export function runArtifactPath(cwd: string, run: ActiveRun, artifactName: string): string {
21
26
  return join(runRoot(cwd, run), artifactName);
22
27
  }
23
-
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import type { ActiveRun, KdPhase, KdQuestion } from "./types.ts";
3
3
  import { PHASE_ARTIFACTS, isKdPhase, nextPhase } from "./types.ts";
4
- import { activeRunPath, kdDir, runsDir } from "./paths.ts";
4
+ import { activeRunPath, kdDir, runStatePath, runsDir } from "./paths.ts";
5
5
  import { defaultArtifactContent, ensureArtifact, ensureRunDirectories, writeArtifact } from "./artifacts.ts";
6
6
  import { canEnterPhase, inspectGate } from "./gates.ts";
7
7
  import { profileForProduct, resolveProductProfile } from "../product/profile.ts";
@@ -11,15 +11,9 @@ export function readActiveRun(cwd: string): ActiveRun | undefined {
11
11
  if (!existsSync(path)) return undefined;
12
12
 
13
13
  try {
14
- const parsed = JSON.parse(readFileSync(path, "utf8")) as ActiveRun;
15
- if (!parsed.id || !isKdPhase(parsed.phase)) return undefined;
16
- parsed.artifacts ??= {};
17
- parsed.questions ??= [];
18
- const legacyEdition = (parsed as ActiveRun & { edition?: string }).edition;
19
- parsed.profile = parsed.profile ?? profileForProduct(parsed.product ?? resolveProductProfile(legacyEdition).product);
20
- parsed.product = parsed.profile.product;
21
- parsed.gate ??= { passed: false, checkedAt: new Date().toISOString() };
22
- return parsed;
14
+ const active = hydrateRun(JSON.parse(readFileSync(path, "utf8")) as ActiveRun);
15
+ if (!active) return undefined;
16
+ return readRun(cwd, active.id) ?? active;
23
17
  } catch {
24
18
  return undefined;
25
19
  }
@@ -28,15 +22,60 @@ export function readActiveRun(cwd: string): ActiveRun | undefined {
28
22
  export function writeActiveRun(cwd: string, run: ActiveRun): void {
29
23
  mkdirSync(kdDir(cwd), { recursive: true });
30
24
  mkdirSync(runsDir(cwd), { recursive: true });
25
+ run.status = "active";
26
+ run.updatedAt = new Date().toISOString();
27
+ writeRunState(cwd, run);
31
28
  writeFileSync(activeRunPath(cwd), `${JSON.stringify(run, null, 2)}\n`, "utf8");
32
29
  }
33
30
 
31
+ export function readRun(cwd: string, id: string): ActiveRun | undefined {
32
+ const path = runStatePath(cwd, id);
33
+ if (!existsSync(path)) return undefined;
34
+ try {
35
+ return hydrateRun(JSON.parse(readFileSync(path, "utf8")) as ActiveRun);
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ export function listRuns(cwd: string): ActiveRun[] {
42
+ const root = runsDir(cwd);
43
+ if (!existsSync(root)) return [];
44
+ return readdirSync(root, { withFileTypes: true })
45
+ .filter((entry) => entry.isDirectory())
46
+ .map((entry) => readRun(cwd, entry.name))
47
+ .filter((run): run is ActiveRun => Boolean(run))
48
+ .sort((a, b) => (b.updatedAt ?? b.createdAt ?? b.id).localeCompare(a.updatedAt ?? a.createdAt ?? a.id));
49
+ }
50
+
51
+ export function switchActiveRun(cwd: string, id: string): ActiveRun | undefined {
52
+ const run = readRun(cwd, id);
53
+ if (!run) return undefined;
54
+ writeActiveRun(cwd, run);
55
+ return readActiveRun(cwd);
56
+ }
57
+
58
+ export function finishActiveRun(cwd: string, run: ActiveRun): ActiveRun {
59
+ run.status = "done";
60
+ run.updatedAt = new Date().toISOString();
61
+ run.gate = inspectGate(cwd, run);
62
+ writeRunState(cwd, run);
63
+ const activePath = activeRunPath(cwd);
64
+ if (existsSync(activePath)) unlinkSync(activePath);
65
+ return run;
66
+ }
67
+
34
68
  export function createActiveRun(cwd: string, goal: string, productInput?: string, version?: string): ActiveRun {
35
69
  const profile = resolveProductProfile(productInput ?? goal);
70
+ const now = new Date().toISOString();
36
71
  const run: ActiveRun = {
37
72
  id: createRunId(goal),
73
+ goal,
38
74
  phase: "discuss",
39
75
  cwd,
76
+ status: "active",
77
+ createdAt: now,
78
+ updatedAt: now,
40
79
  product: profile.product,
41
80
  version,
42
81
  profile,
@@ -145,6 +184,26 @@ export function refreshGate(cwd: string, run: ActiveRun): ActiveRun {
145
184
  return run;
146
185
  }
147
186
 
187
+ function writeRunState(cwd: string, run: ActiveRun): void {
188
+ mkdirSync(runsDir(cwd), { recursive: true });
189
+ ensureRunDirectories(cwd, run);
190
+ writeFileSync(runStatePath(cwd, run), `${JSON.stringify(run, null, 2)}\n`, "utf8");
191
+ }
192
+
193
+ function hydrateRun(parsed: ActiveRun): ActiveRun | undefined {
194
+ if (!parsed.id || !isKdPhase(parsed.phase)) return undefined;
195
+ parsed.artifacts ??= {};
196
+ parsed.questions ??= [];
197
+ parsed.status ??= "active";
198
+ parsed.createdAt ??= parsed.updatedAt;
199
+ parsed.updatedAt ??= parsed.createdAt;
200
+ const legacyEdition = (parsed as ActiveRun & { edition?: string }).edition;
201
+ parsed.profile = parsed.profile ?? profileForProduct(parsed.product ?? resolveProductProfile(legacyEdition).product);
202
+ parsed.product = parsed.profile.product;
203
+ parsed.gate ??= { passed: false, checkedAt: new Date().toISOString() };
204
+ return parsed;
205
+ }
206
+
148
207
  function createRunId(goal: string): string {
149
208
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
150
209
  const slug = goal
@@ -2,6 +2,7 @@ import type { KdProduct, ProductProfile } from "../product/profile.ts";
2
2
 
3
3
  export type KdPhase = "discuss" | "spec" | "plan" | "execute" | "verify" | "ship";
4
4
  export type KdRisk = "unknown" | "low" | "medium" | "high";
5
+ export type KdRunStatus = "active" | "paused" | "done";
5
6
 
6
7
  export interface GateResult {
7
8
  passed: boolean;
@@ -24,8 +25,12 @@ export interface KdQuestion {
24
25
 
25
26
  export interface ActiveRun {
26
27
  id: string;
28
+ goal?: string;
27
29
  phase: KdPhase;
28
30
  cwd: string;
31
+ status?: KdRunStatus;
32
+ createdAt?: string;
33
+ updatedAt?: string;
29
34
  product?: KdProduct;
30
35
  version?: string;
31
36
  profile?: ProductProfile;