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 +36 -2
- package/extensions/kingdee-harness.ts +145 -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]
|
|
@@ -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`
|
|
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.
|
|
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) => {
|
|
@@ -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"
|
|
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
|
@@ -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;
|