kcode-pi 0.1.8 → 0.1.12

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]
@@ -286,6 +289,8 @@ kcode start --provider openai --model gpt-4o
286
289
  /kd-start --product flagship 实现采购订单插件
287
290
  ```
288
291
 
292
+ `/kd-start` 会创建新的功能点 run,并立即触发 Agent 进入 `discuss` 阶段。如果未识别出产品画像,例如显示 `(unknown/unknown)`,下一步应先确认产品、版本和技术栈,而不是直接写代码。
293
+
289
294
  支持的产品画像:
290
295
 
291
296
  ```text
@@ -310,12 +315,30 @@ discuss -> spec -> plan -> execute -> verify -> ship
310
315
 
311
316
  KCode 会把金蝶开发需求纳入 Harness 工作流。你可以直接输入自然语言需求;如果当前项目还没有 active run,KCode 会自动创建 run 并进入 `discuss` 阶段,而不是直接生成代码。
312
317
 
318
+ 一个功能点对应一个 run。每个 run 都有独立阶段、计划、执行记录和证据;做另一个功能点时,不要复用上一个功能点的阶段。
319
+
320
+ ```text
321
+ /kd-start 实现采购订单保存校验
322
+ /kd-finish
323
+ /kd-start 实现采购订单列表字段展示
324
+ ```
325
+
326
+ 查看和切换当前项目里的功能点 run:
327
+
328
+ ```text
329
+ /kd-runs
330
+ /kd-switch <run-id>
331
+ ```
332
+
333
+ 如果当前 active run 处于 `execute` 或 `verify`,但你提出的是另一个需求,KCode 会要求先创建新 run 或切换 run,避免把新需求塞进旧功能点的 `PLAN.md` 和阶段门禁。
334
+
313
335
  每次进入 Harness 时,KCode 会附带 `.pi/kd/PROJECT_CONTEXT.md`。这份上下文不会替代计划阶段的实际文件检查,但会让 Agent 在暂停、恢复、重新打开终端后仍知道当前项目的源码根、模块线索和禁止猜路径规则。
314
336
 
315
337
  每个需求 run 的设计和执行文档会落到本地:
316
338
 
317
339
  ```text
318
340
  .pi/kd/active-run.json
341
+ .pi/kd/runs/<run-id>/RUN.json
319
342
  .pi/kd/runs/<run-id>/CONTEXT.md
320
343
  .pi/kd/runs/<run-id>/SPEC.md
321
344
  .pi/kd/runs/<run-id>/PLAN.md
@@ -325,7 +348,7 @@ KCode 会把金蝶开发需求纳入 Harness 工作流。你可以直接输入
325
348
  .pi/kd/runs/<run-id>/evidence/
326
349
  ```
327
350
 
328
- 下次重新 `kcode start` 时,KCode 会读取当前项目的 active run、项目常驻上下文和已生成的阶段文档,再继续当前阶段。
351
+ 下次重新 `kcode start` 时,KCode 会读取当前项目的 active run、项目常驻上下文和已生成的阶段文档,再继续当前功能点的当前阶段。已完成或暂停的功能点仍保留在 `.pi/kd/runs/<run-id>/`,可用 `/kd-runs` 查看,用 `/kd-switch <run-id>` 切回。
329
352
 
330
353
  阶段含义:
331
354
 
@@ -388,7 +411,17 @@ ship 汇总变更、验证证据、风险和后续事项
388
411
 
389
412
  如果 LLM 跳过步骤、没有记录 evidence,或只是口头声明完成,KCode 不允许进入 `verify`。
390
413
 
391
- 结构化问题也会进入门禁。Agent 需要确认产品、目标单据、插件位置、高风险 SQL 或方案选择时,会使用 `kd_question` 记录问题;未回答的阻断问题会阻止进入下一阶段。用户回复后,Agent 会记录答案;也可以手动执行:
414
+ 结构化问题也会进入门禁。Agent 需要确认产品、目标单据、插件位置、高风险 SQL 或方案选择时,会使用 `kd_question` 记录问题;未回答的阻断问题会阻止进入下一阶段。
415
+
416
+ `kd_question` 一次只允许问一个当前最阻塞的问题,不能把多个问题打包成编号清单。比如应先问:
417
+
418
+ ```text
419
+ 采购入库单 Form ID 是否为 pur_receivebill?
420
+ ```
421
+
422
+ 得到回答后,再继续问触发时机、库存条件、弹窗内容或插件位置。
423
+
424
+ 在 `kcode start` 的交互式 TUI 中,`kd_question` 会弹出 KCode Question 选择/输入对话,并在用户选择或输入后自动记录答案。非交互模式或 UI 不可用时,问题会显示在对话里,用户直接回复;Agent 读取回复后再记录答案。也可以手动执行:
392
425
 
393
426
  ```text
394
427
  /kd-answer Q-001 采购订单
@@ -399,6 +432,7 @@ ship 汇总变更、验证证据、风险和后续事项
399
432
  ```text
400
433
  .pi/kd/active-run.json
401
434
  .pi/kd/runs/<run-id>/
435
+ .pi/kd/runs/<run-id>/RUN.json
402
436
  ```
403
437
 
404
438
  ## 内置金蝶工具
@@ -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) => {
@@ -311,6 +357,46 @@ export default function (pi: ExtensionAPI) {
311
357
 
312
358
  const run = createActiveRun(ctx.cwd, goal, parsed.product, parsed.version);
313
359
  ctx.ui.notify(`Started Kingdee harness run: ${run.id} (${run.profile?.product}/${run.profile?.techStack})`, "info");
360
+ const kickoff = `继续 KCode Harness run ${run.id}:${goal}`;
361
+ if (ctx.isIdle()) {
362
+ pi.sendUserMessage(kickoff);
363
+ } else {
364
+ pi.sendUserMessage(kickoff, { deliverAs: "followUp" });
365
+ ctx.ui.notify("KCode harness kickoff queued.", "info");
366
+ }
367
+ },
368
+ });
369
+
370
+ pi.registerCommand("kd-switch", {
371
+ description: "Switch active Kingdee harness run: /kd-switch <run-id>",
372
+ handler: async (args, ctx) => {
373
+ const id = args.trim();
374
+ if (!id) {
375
+ ctx.ui.notify("Usage: /kd-switch <run-id>", "error");
376
+ return;
377
+ }
378
+
379
+ const run = switchActiveRun(ctx.cwd, id);
380
+ if (!run) {
381
+ ctx.ui.notify(`Kingdee harness run not found: ${id}. Use /kd-runs to list runs.`, "error");
382
+ return;
383
+ }
384
+
385
+ ctx.ui.notify(`Switched active Kingdee harness run: ${run.id} (${run.phase})`, "info");
386
+ },
387
+ });
388
+
389
+ pi.registerCommand("kd-finish", {
390
+ description: "Mark the active Kingdee harness run as done and clear active run",
391
+ handler: async (_args, ctx) => {
392
+ const run = requireRun(ctx.cwd);
393
+ if (!run) {
394
+ ctx.ui.notify("No active Kingdee harness run. Use /kd-start <goal>.", "error");
395
+ return;
396
+ }
397
+
398
+ const finished = finishActiveRun(ctx.cwd, run);
399
+ ctx.ui.notify(`Finished Kingdee harness run: ${finished.id}. Start the next feature with /kd-start <goal>.`, "info");
314
400
  },
315
401
  });
316
402
 
@@ -411,6 +497,20 @@ function formatQuestions(run: NonNullable<ReturnType<typeof readActiveRun>>): st
411
497
  return questions.map(formatQuestionCard).join("\n\n");
412
498
  }
413
499
 
500
+ function formatRuns(runs: NonNullable<ReturnType<typeof readActiveRun>>[], activeId?: string): string {
501
+ if (runs.length === 0) return "No Kingdee harness runs in this project. Start one with /kd-start <goal>.";
502
+ return runs
503
+ .map((run) =>
504
+ [
505
+ `${run.id === activeId ? "*" : "-"} ${run.id}`,
506
+ ` Goal: ${run.goal ?? "unknown"}`,
507
+ ` Status: ${run.status ?? "active"} | Phase: ${run.phase} | Product: ${run.profile?.product ?? run.product ?? "unknown"}`,
508
+ ` Updated: ${run.updatedAt ?? "unknown"}`,
509
+ ].join("\n"),
510
+ )
511
+ .join("\n\n");
512
+ }
513
+
414
514
  function formatQuestionCard(question: NonNullable<NonNullable<ReturnType<typeof readActiveRun>>["questions"]>[number]): string {
415
515
  const lines = [
416
516
  `Question ${question.id} [${question.status}${question.blocking ? ", blocking" : ""}]`,
@@ -419,11 +519,49 @@ function formatQuestionCard(question: NonNullable<NonNullable<ReturnType<typeof
419
519
  question.reason ? `Reason: ${question.reason}` : undefined,
420
520
  question.choices?.length ? `Choices: ${question.choices.join(" | ")}` : undefined,
421
521
  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,
522
+ question.status === "open"
523
+ ? `Answer the dialog if available, or reply in chat and record it using kd_question action=answer id=${question.id} answer=<answer>.`
524
+ : undefined,
423
525
  ];
424
526
  return lines.filter(Boolean).join("\n");
425
527
  }
426
528
 
529
+ async function askQuestionInteractively(
530
+ ctx: ExtensionContext,
531
+ question: string,
532
+ choices: string[] | undefined,
533
+ ): Promise<string | undefined> {
534
+ if (!ctx.hasUI) return undefined;
535
+
536
+ const normalizedChoices = choices?.map((choice) => choice.trim()).filter(Boolean) ?? [];
537
+ try {
538
+ if (normalizedChoices.length === 0) {
539
+ return (await ctx.ui.input(question, "请输入答案"))?.trim() || undefined;
540
+ }
541
+
542
+ const customChoice = "其他,自定义输入";
543
+ const selected = await ctx.ui.select(question, [...normalizedChoices, customChoice]);
544
+ if (!selected) return undefined;
545
+ if (selected !== customChoice) return selected;
546
+ return (await ctx.ui.input(question, "请输入答案"))?.trim() || undefined;
547
+ } catch {
548
+ return undefined;
549
+ }
550
+ }
551
+
552
+ function questionBatchProblem(question: string, choices?: string[]): string | undefined {
553
+ const text = question.trim();
554
+ const numberedItems = text.split(/\r?\n/).filter((line) => /^\s*(\d+[\.\)、)]|[-*]\s+)/.test(line)).length;
555
+ if (numberedItems >= 2) return "kd_question rejected a batched checklist question.";
556
+ if ((text.match(/[??]/g) ?? []).length > 1) return "kd_question rejected multiple questions in one prompt.";
557
+ if (text.length > 220) return "kd_question rejected an overlong question. Ask only the smallest blocking question first.";
558
+ if ((choices?.length ?? 0) > 3) return "kd_question rejected too many choices. Provide at most 3 concise choices.";
559
+ if (choices?.some((choice) => choice.length > 40 || /[\r\n??]/.test(choice))) {
560
+ return "kd_question rejected complex choices. Choices must be short labels, not nested questions.";
561
+ }
562
+ return undefined;
563
+ }
564
+
427
565
  function formatQuestionArtifactLines(question: NonNullable<NonNullable<ReturnType<typeof readActiveRun>>["questions"]>[number]): string[] {
428
566
  return [
429
567
  `- ${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.12",
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;