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 +34 -2
- package/extensions/kingdee-harness.ts +138 -7
- package/package.json +1 -1
- package/skills/kd-discuss/SKILL.md +1 -0
- package/skills/kd-spec/SKILL.md +1 -2
- package/src/harness/format.ts +2 -0
- package/src/harness/paths.ts +5 -1
- package/src/harness/state.ts +70 -11
- package/src/harness/types.ts +5 -0
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`
|
|
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.
|
|
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: "
|
|
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
|
|
236
|
-
|
|
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"
|
|
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
|
@@ -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`.
|
package/skills/kd-spec/SKILL.md
CHANGED
|
@@ -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.
|
package/src/harness/format.ts
CHANGED
|
@@ -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)}`,
|
package/src/harness/paths.ts
CHANGED
|
@@ -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
|
-
|
package/src/harness/state.ts
CHANGED
|
@@ -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
|
|
15
|
-
if (!
|
|
16
|
-
|
|
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
|
package/src/harness/types.ts
CHANGED
|
@@ -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;
|