paseo-req-kit 0.0.0-stage → 1.7.0-beta.3

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/SCOPE.md +11 -0
  5. package/bin/locate.mjs +46 -0
  6. package/bin/req-doctor.mjs +21 -0
  7. package/bin/req.mjs +175 -0
  8. package/bin/start-pipeline +21 -0
  9. package/bin/start-pipeline.mjs +22 -0
  10. package/config.example.json +101 -0
  11. package/contracts/README.md +18 -0
  12. package/contracts/feature-files.md +87 -0
  13. package/contracts/notifier-event.md +71 -0
  14. package/contracts/result-file.md +17 -0
  15. package/contracts/spec.md +34 -0
  16. package/contracts/state.md +44 -0
  17. package/contracts/verify.md +39 -0
  18. package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
  19. package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
  20. package/docs/adr/0003-script-engine-not-hub.md +15 -0
  21. package/docs/adr/0004-result-files.md +15 -0
  22. package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
  23. package/docs/adr/0006-skill-install-targets.md +15 -0
  24. package/docs/adr/0007-own-interview-skill.md +15 -0
  25. package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
  26. package/docs/adr/0009-versioned-install-layout.md +15 -0
  27. package/docs/adr/0010-generated-feature-overview.md +15 -0
  28. package/docs/adr/0011-resume-after-completion.md +15 -0
  29. package/docs/adr/0012-review-panel.md +18 -0
  30. package/docs/adr/0013-no-release-tooling.md +15 -0
  31. package/docs/adr/0014-notifier-slot.md +19 -0
  32. package/docs/adr/0015-incremental-continue.md +17 -0
  33. package/docs/adr/0016-upgrade-from-tags.md +19 -0
  34. package/docs/adr/0017-use-paseo-capabilities.md +18 -0
  35. package/docs/adr/0018-own-worktree-option.md +21 -0
  36. package/docs/adr/0019-parallel-orchestration.md +21 -0
  37. package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
  38. package/docs/adr/0021-npm-version-manager.md +19 -0
  39. package/docs/adr/0022-develop-and-verify-phases.md +32 -0
  40. package/docs/adr/README.md +26 -0
  41. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +78 -0
  42. package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
  43. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
  44. package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +251 -0
  45. package/engine/prompts/00-shared-context.md +100 -0
  46. package/engine/prompts/02-spec-review.md +46 -0
  47. package/engine/prompts/03-design.md +50 -0
  48. package/engine/prompts/03a-design-proposal.md +30 -0
  49. package/engine/prompts/04-design-review.md +72 -0
  50. package/engine/prompts/04b-design-revise.md +34 -0
  51. package/engine/prompts/05-implement.md +66 -0
  52. package/engine/prompts/06-code-review.md +66 -0
  53. package/engine/prompts/07-review-fix.md +41 -0
  54. package/engine/prompts/07b-handoff.md +45 -0
  55. package/engine/prompts/08-service-start.md +59 -0
  56. package/engine/prompts/09-verify.md +90 -0
  57. package/engine/prompts/10-defect-fix.md +41 -0
  58. package/engine/prompts/10b-reverify.md +43 -0
  59. package/engine/prompts/11-report.md +93 -0
  60. package/engine/prompts/browser-verify.md +13 -0
  61. package/engine/prompts/incremental.md +21 -0
  62. package/engine/prompts/panel-member.md +38 -0
  63. package/engine/prompts/paseo-services.md +9 -0
  64. package/engine/prompts/verify-deployed.md +14 -0
  65. package/engine/src/activity.ts +87 -0
  66. package/engine/src/cli-args.ts +204 -0
  67. package/engine/src/config.ts +479 -0
  68. package/engine/src/configure.ts +237 -0
  69. package/engine/src/control.ts +230 -0
  70. package/engine/src/daemon-check.ts +104 -0
  71. package/engine/src/daemon-version.ts +66 -0
  72. package/engine/src/digest.ts +133 -0
  73. package/engine/src/doctor.ts +435 -0
  74. package/engine/src/eval.ts +324 -0
  75. package/engine/src/features.ts +268 -0
  76. package/engine/src/git.ts +30 -0
  77. package/engine/src/launch.ts +405 -0
  78. package/engine/src/locations.ts +55 -0
  79. package/engine/src/main.ts +313 -0
  80. package/engine/src/messages.ts +308 -0
  81. package/engine/src/notifiers.ts +76 -0
  82. package/engine/src/notify-test.ts +97 -0
  83. package/engine/src/paseo-json.ts +73 -0
  84. package/engine/src/paseo-profiles.ts +133 -0
  85. package/engine/src/paseo-runtime.ts +437 -0
  86. package/engine/src/paths.ts +10 -0
  87. package/engine/src/pipeline.ts +1126 -0
  88. package/engine/src/preflight.ts +298 -0
  89. package/engine/src/prompts.ts +72 -0
  90. package/engine/src/queue.ts +61 -0
  91. package/engine/src/runtime-loader.ts +14 -0
  92. package/engine/src/runtime.ts +155 -0
  93. package/engine/src/schedule-time.ts +51 -0
  94. package/engine/src/smoke-test.ts +202 -0
  95. package/engine/src/spec-review.ts +100 -0
  96. package/engine/src/state.ts +278 -0
  97. package/engine/src/steps.ts +242 -0
  98. package/engine/src/usage.ts +67 -0
  99. package/engine/src/worktree.ts +72 -0
  100. package/install.mjs +928 -0
  101. package/notifiers/im-template.mjs +58 -0
  102. package/package.json +67 -4
  103. package/principles/INDEX.md +11 -0
  104. package/skills/acceptance-criteria/SKILL.md +52 -0
  105. package/skills/principle-delete-before-add/SKILL.md +33 -0
  106. package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
  107. package/skills/principle-fix-root-cause/SKILL.md +33 -0
  108. package/skills/principle-prove-it-works/SKILL.md +33 -0
  109. package/skills/principle-report-honestly/SKILL.md +33 -0
  110. package/skills/principle-speak-to-reader/SKILL.md +33 -0
  111. package/skills/principle-this-requirement-only/SKILL.md +33 -0
  112. package/skills/req/SKILL.md +99 -0
  113. package/skills/req/playbooks/accept.md +65 -0
  114. package/skills/req/playbooks/change.md +51 -0
  115. package/skills/req/playbooks/incident.md +55 -0
  116. package/skills/req/playbooks/new.md +83 -0
  117. package/skills/req/playbooks/small.md +48 -0
  118. package/skills/req/playbooks/status.md +39 -0
  119. package/skills/req/playbooks/takeover.md +51 -0
  120. package/skills/req/playbooks/verify.md +57 -0
  121. package/skills/req-doctor/SKILL.md +36 -0
  122. package/skills/req-retro/SKILL.md +79 -0
  123. package/skills/req-setup/SKILL.md +101 -0
  124. package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
  125. package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
  126. package/skills/requirement-interview/SKILL.md +94 -0
  127. package/skills/requirement-kickoff/SKILL.md +17 -0
  128. package/skills/result-report/SKILL.md +34 -0
  129. package/skills/spec-writing/SKILL.md +41 -0
  130. package/skills/spec-writing/spec-template.md +106 -0
@@ -0,0 +1,202 @@
1
+ // Checks the parts of the pipeline that depend on your real Paseo setup.
2
+ //
3
+ // npm run smoke -- check daemon, providers, models, modes; one tiny agent per role
4
+ // npm run smoke -- notify --origin-agent <id> message + subagent into an existing chat
5
+ // npm run smoke -- detach-start start a background heartbeat (run from a Paseo chat)
6
+ // npm run smoke -- detach-check is that heartbeat still alive?
7
+ //
8
+ // See docs/guide/安装与升级.md, "冒烟测试", for when to run each one.
9
+
10
+ import { spawn } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { parseArgs } from "node:util";
15
+ import { type AgentProfile, loadConfig, type PipelineConfig } from "./config.js";
16
+ import { checkProfile as checkProfileWith, type Client, connect, type Report, resolveWithDaemon } from "./daemon-check.js";
17
+ import { defaultConfigPath } from "./locations.js";
18
+
19
+ const HEARTBEAT = path.join(os.tmpdir(), "requirement-pipeline-heartbeat.json");
20
+
21
+ function line(ok: boolean, text: string): void {
22
+ console.log(`${ok ? "✓" : "✗"} ${text}`);
23
+ }
24
+
25
+ const report: Report = (level, text, fix) => {
26
+ line(level !== "fail", text);
27
+ if (fix) console.log(` ${fix}`);
28
+ };
29
+
30
+ async function tinyAgent(client: Client, role: string, profile: AgentProfile): Promise<boolean> {
31
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `requirement-pipeline-smoke-${role}-`));
32
+ const agent = await client.agents.create({
33
+ config: {
34
+ provider: profile.provider ?? "",
35
+ ...(profile.modeId ? { modeId: profile.modeId } : {}),
36
+ ...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
37
+ ...(profile.featureValues ? { featureValues: profile.featureValues } : {}),
38
+ },
39
+ cwd: dir,
40
+ title: `[冒烟测试] ${role}`,
41
+ labels: { "requirement-pipeline": "smoke-test" },
42
+ prompt: `在当前目录创建文件 smoke.txt,内容只有一行:ok-${role}。然后用 node 执行 \`node -e "require('fs').writeFileSync('shell.txt','ran')"\`。完成后直接结束,不要做其他事。`,
43
+ });
44
+ console.log(` 已创建 agent ${agent.id},等待它完成(最多 5 分钟)……`);
45
+ try {
46
+ const result = await agent.waitForFinish(5 * 60_000);
47
+ if (result.status === "permission") {
48
+ line(false, `${role}:agent 停下来等待权限批准。请把 agents.${role}.modeId 改成免确认的模式。`);
49
+ return false;
50
+ }
51
+ if (result.status !== "idle") {
52
+ line(false, `${role}:agent 结束状态为 ${result.status}:${result.error ?? ""}`);
53
+ return false;
54
+ }
55
+ const wrote = fs.existsSync(path.join(dir, "smoke.txt"));
56
+ const ran = fs.existsSync(path.join(dir, "shell.txt"));
57
+ line(wrote && ran, `${role}:agent 能写文件${wrote ? "" : "(失败)"}、能执行命令${ran ? "" : "(失败)"}`);
58
+ return wrote && ran;
59
+ } finally {
60
+ await agent.archive().catch(() => undefined);
61
+ }
62
+ }
63
+
64
+ async function check(config: PipelineConfig): Promise<number> {
65
+ let client: Client;
66
+ try {
67
+ client = await connect(config);
68
+ line(true, `已连接 daemon:${config.daemonUrl}`);
69
+ } catch (error) {
70
+ line(false, `无法连接 daemon ${config.daemonUrl}:${(error as Error).message}`);
71
+ return 1;
72
+ }
73
+ try {
74
+ const snapshot = await client.providers.waitForReady({ timeoutMs: 60_000 });
75
+ console.log(" daemon 上可用的 provider:");
76
+ for (const entry of snapshot.entries) {
77
+ console.log(` - ${entry.provider}(${entry.status})`);
78
+ }
79
+ try {
80
+ config = await resolveWithDaemon(client, config);
81
+ } catch (error) {
82
+ line(false, (error as Error).message);
83
+ return 1;
84
+ }
85
+ let ok = true;
86
+ for (const role of ["builder", "reviewer"] as const) {
87
+ ok = (await checkProfileWith(client, role, config.agents[role], report)) && ok;
88
+ }
89
+ if (!ok) {
90
+ console.log("\n请先修正 ~/.req-kit/config.json 中的 provider/model 和 modeId,再重新运行。");
91
+ return 1;
92
+ }
93
+ for (const role of ["builder", "reviewer"] as const) {
94
+ ok = (await tinyAgent(client, role, config.agents[role])) && ok;
95
+ }
96
+ console.log(ok ? "\n全部通过。" : "\n有检查没有通过,见上面的 ✗。");
97
+ return ok ? 0 : 1;
98
+ } finally {
99
+ await client.close();
100
+ }
101
+ }
102
+
103
+ async function notify(config: PipelineConfig, origin: string): Promise<number> {
104
+ const client = await connect(config);
105
+ try {
106
+ await client.agents.ref(origin).send(
107
+ "【需求开发流水线通知】这是一条冒烟测试消息,不是用户发的。请回复用户:\"流水线的通知通道工作正常。\"",
108
+ { activeTurnBehavior: "steer" },
109
+ );
110
+ line(true, `已向 agent ${origin} 发送测试通知。请到该对话中确认 agent 转告了"通知通道工作正常"。`);
111
+
112
+ config = await resolveWithDaemon(client, config);
113
+ const child = await client.agents.create({
114
+ config: {
115
+ provider: config.agents.builder.provider ?? "",
116
+ ...(config.agents.builder.modeId ? { modeId: config.agents.builder.modeId } : {}),
117
+ },
118
+ cwd: process.cwd(),
119
+ parent: origin,
120
+ title: "[冒烟测试] 子 agent",
121
+ labels: { "requirement-pipeline": "smoke-test" },
122
+ prompt: "只回复\"ok\",然后结束。不要读写任何文件。",
123
+ });
124
+ const result = await child.waitForFinish(3 * 60_000);
125
+ line(result.status === "idle", `已在该对话下创建子 agent ${child.id}(结束状态 ${result.status})。请确认它出现在对话的子 agent 列表中。`);
126
+ await child.archive().catch(() => undefined);
127
+ return result.status === "idle" ? 0 : 1;
128
+ } catch (error) {
129
+ line(false, (error as Error).message);
130
+ return 1;
131
+ } finally {
132
+ await client.close();
133
+ }
134
+ }
135
+
136
+ function detachStart(): number {
137
+ const script = `
138
+ const fs = require("fs");
139
+ const end = Date.now() + 10 * 60_000;
140
+ const beat = () => fs.writeFileSync(${JSON.stringify(HEARTBEAT)}, JSON.stringify({ pid: process.pid, at: Date.now(), startedAt }));
141
+ const startedAt = Date.now();
142
+ beat();
143
+ const timer = setInterval(() => { if (Date.now() > end) { clearInterval(timer); return; } beat(); }, 2000);
144
+ `;
145
+ // Same detach mechanism as launch.ts.
146
+ const child = spawn(process.execPath, ["-e", script], { detached: true, windowsHide: true, stdio: "ignore" });
147
+ child.unref();
148
+ console.log(`已启动后台心跳进程 ${child.pid}(持续 10 分钟),心跳文件:${HEARTBEAT}`);
149
+ console.log("请结束这一轮对话,等待至少 1 分钟后,再让 agent 运行 detach-check。");
150
+ return 0;
151
+ }
152
+
153
+ function detachCheck(): number {
154
+ if (!fs.existsSync(HEARTBEAT)) {
155
+ line(false, "找不到心跳文件,请先运行 detach-start");
156
+ return 1;
157
+ }
158
+ const beat = JSON.parse(fs.readFileSync(HEARTBEAT, "utf8")) as { pid: number; at: number; startedAt: number };
159
+ const age = Math.round((Date.now() - beat.at) / 1000);
160
+ const ranFor = Math.round((beat.at - beat.startedAt) / 1000);
161
+ const alive = age < 10;
162
+ line(
163
+ alive,
164
+ alive
165
+ ? `后台进程 ${beat.pid} 仍在运行(已运行 ${ranFor} 秒)。聊天 agent 启动的后台流水线可以在它的回合结束后继续运行。`
166
+ : `后台进程 ${beat.pid} 在启动后约 ${ranFor} 秒停止(最后一次心跳在 ${age} 秒前)。` +
167
+ "聊天 agent 的回合结束时,它启动的后台进程被终止了;需要改用插件方式启动流水线。",
168
+ );
169
+ return alive ? 0 : 1;
170
+ }
171
+
172
+ async function main(): Promise<number> {
173
+ const { values, positionals } = parseArgs({
174
+ args: process.argv.slice(2),
175
+ options: { "origin-agent": { type: "string" }, config: { type: "string" } },
176
+ allowPositionals: true,
177
+ });
178
+ const command = positionals[0];
179
+ if (command === "detach-start") return detachStart();
180
+ if (command === "detach-check") return detachCheck();
181
+
182
+ const config = loadConfig(values.config ? path.resolve(values.config) : defaultConfigPath()).config;
183
+ if (command === "check") return check(config);
184
+ if (command === "notify") {
185
+ const origin = values["origin-agent"] ?? process.env.PASEO_AGENT_ID;
186
+ if (!origin) {
187
+ console.error("需要 --origin-agent <agent ID>,或在 Paseo 的聊天中运行(自动读取 PASEO_AGENT_ID)");
188
+ return 2;
189
+ }
190
+ return notify(config, origin);
191
+ }
192
+ console.error("用法:npm run smoke -- check | notify --origin-agent <id> | detach-start | detach-check");
193
+ return 2;
194
+ }
195
+
196
+ main().then(
197
+ (code) => process.exit(code),
198
+ (error: Error) => {
199
+ console.error(error.message);
200
+ process.exit(1);
201
+ },
202
+ );
@@ -0,0 +1,100 @@
1
+ // Independent review of a draft spec, started from the chat before the requester
2
+ // confirms it (launch.ts --spec-review). Runs one "spec_review" agent under the chat,
3
+ // waits for it in the background, then posts the findings back into the chat.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { parseLaunchArgs } from "./cli-args.js";
8
+ import { loadConfig } from "./config.js";
9
+ import { defaultConfigPath, KIT_ROOT, localPromptsDir, PRINCIPLES_INDEX, PROMPTS_DIR } from "./locations.js";
10
+ import { specReviewFailedMessage, specReviewMessage } from "./messages.js";
11
+ import { resolveConfigProfiles, usesPaseoProfiles } from "./paseo-profiles.js";
12
+ import { runSingleStep } from "./pipeline.js";
13
+ import { normalizeFeatureDir, specProfile } from "./preflight.js";
14
+ import { loadRuntimeFactory } from "./runtime-loader.js";
15
+ import { ensurePipelineDir, hashFile, type PipelineState, pipelineDir, STATE_VERSION } from "./state.js";
16
+
17
+ function log(message: string): void {
18
+ console.log(`[${new Date().toISOString()}] ${message}`);
19
+ }
20
+
21
+ async function main(): Promise<number> {
22
+ const args = parseLaunchArgs(process.argv.slice(2));
23
+ const repo = fs.realpathSync(args.repo);
24
+ const featureDir = normalizeFeatureDir(repo, args.featureDir);
25
+ const specPath = path.join(repo, featureDir, "spec.md");
26
+ let config = loadConfig(args.configPath ?? defaultConfigPath(), repo).config;
27
+ ensurePipelineDir(repo, featureDir);
28
+ const runtime = await (await loadRuntimeFactory())({ config, log });
29
+ const origin = args.originAgentId;
30
+ const notify = async (text: string) => {
31
+ log(`通知:\n${text}`);
32
+ if (origin) await runtime.notifyAgent(origin, text).catch((error: Error) => log(`通知发送失败:${error.message}`));
33
+ };
34
+ try {
35
+ if (origin) await runtime.verifyOrigin(origin, repo);
36
+ if (usesPaseoProfiles(config)) config = resolveConfigProfiles(config, await runtime.listPaseoProfiles());
37
+ const now = new Date().toISOString();
38
+ // A scratch state: the review is not part of a pipeline run and must not touch state.json.
39
+ const state: PipelineState = {
40
+ version: STATE_VERSION,
41
+ repo,
42
+ featureDir,
43
+ status: "running",
44
+ attempt: 1,
45
+ specHash: hashFile(specPath),
46
+ originAgentId: origin,
47
+ pid: process.pid,
48
+ startedAt: now,
49
+ updatedAt: now,
50
+ results: {},
51
+ history: [],
52
+ stopReason: null,
53
+ outcome: null,
54
+ profile: specProfile(fs.readFileSync(specPath, "utf8")) ?? config.defaultProfile,
55
+ branch: null,
56
+ milestones: [],
57
+ incremental: null,
58
+ };
59
+ const result = await runSingleStep(
60
+ {
61
+ repo,
62
+ featureDir,
63
+ config,
64
+ runtime,
65
+ state,
66
+ statePath: path.join(pipelineDir(repo, featureDir), "spec-review-state.json"),
67
+ prompts: { promptsDir: PROMPTS_DIR, localPromptsDir: localPromptsDir(), principlesFile: PRINCIPLES_INDEX },
68
+ logPath: path.join(pipelineDir(repo, featureDir), "spec-review.log"),
69
+ resumeCommand: "",
70
+ log,
71
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
72
+ },
73
+ "spec_review",
74
+ { contextLines: [`需求说明的写法清单:${path.join(KIT_ROOT, "skills", "spec-writing", "SKILL.md")}`] },
75
+ );
76
+ await notify(
77
+ specReviewMessage({
78
+ featureDir,
79
+ verdict: String(result.verdict),
80
+ issues: (result.issues as string[] | undefined) ?? [],
81
+ reviewPath: `${featureDir}/spec-review.md`,
82
+ }),
83
+ );
84
+ return 0;
85
+ } catch (error) {
86
+ log(`审阅没有完成:${(error as Error).message}`);
87
+ await notify(specReviewFailedMessage({ featureDir, reason: (error as Error).message }));
88
+ return 1;
89
+ } finally {
90
+ await runtime.close().catch(() => undefined);
91
+ }
92
+ }
93
+
94
+ main().then(
95
+ (code) => process.exit(code),
96
+ (error: Error) => {
97
+ console.error(`[${new Date().toISOString()}] 需求说明审阅无法启动:${error.message}`);
98
+ process.exit(2);
99
+ },
100
+ );
@@ -0,0 +1,278 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { DefectHandling, PhaseMode, ProfileName, VerifyEnvironment } from "./config.js";
6
+ import type { AgentUsage } from "./usage.js";
7
+ import type { StepId } from "./steps.js";
8
+
9
+ /**
10
+ * "phase_done": the develop phase is done and the run stopped at the gate, waiting for
11
+ * someone to start the verify phase (phases.mode "gated"). Engines before 1.7 treat an
12
+ * unknown status as an interrupted run, which continues into verification: acceptable.
13
+ */
14
+ export type PipelineStatus = "running" | "waiting_for_business" | "failed" | "completed" | "phase_done";
15
+
16
+ /** Phase names are strings so later phases need no format change. */
17
+ export type PhaseName = "develop" | "verify";
18
+
19
+ /** Execution mode and verify-phase options a run settled on (absent before 1.7: continuous). */
20
+ export interface RunPhases {
21
+ mode: PhaseMode;
22
+ environment: VerifyEnvironment;
23
+ defects: DefectHandling;
24
+ }
25
+
26
+ export interface StepRecord {
27
+ key: string;
28
+ stepId: StepId;
29
+ round: number | null;
30
+ attempt: number;
31
+ agentId: string;
32
+ startedAt: string;
33
+ endedAt: string | null;
34
+ outcome: "running" | "completed" | "failed";
35
+ followUps: number;
36
+ result: Record<string, unknown> | null;
37
+ error: string | null;
38
+ /** Latest usage the agent reported for this step; absent in older state files. */
39
+ usage?: AgentUsage | null;
40
+ /** Phase the step ran in; absent before 1.7 (develop or verify by step). */
41
+ phase?: PhaseName;
42
+ }
43
+
44
+ export interface PipelineState {
45
+ /** Format version, see STATE_VERSION. */
46
+ version: number;
47
+ repo: string;
48
+ featureDir: string;
49
+ status: PipelineStatus;
50
+ /** Incremented every time the pipeline starts over from the design step. */
51
+ attempt: number;
52
+ specHash: string;
53
+ originAgentId: string | null;
54
+ pid: number;
55
+ startedAt: string;
56
+ updatedAt: string;
57
+ /** Completed step results by step key. A resumed run reuses these instead of re-running the step. */
58
+ results: Record<string, Record<string, unknown>>;
59
+ history: StepRecord[];
60
+ stopReason: string | null;
61
+ outcome: string | null;
62
+ /** "quick" or "standard"; decides loop rounds and optional steps. */
63
+ profile: ProfileName;
64
+ /** Git branch the run commits to, fixed at launch. Null outside a git repository. */
65
+ branch: string | null;
66
+ /** Progress notes already sent, so a resumed run does not repeat them. */
67
+ milestones: string[];
68
+ /**
69
+ * Set when this attempt restarted from design because the requirement changed (or a
70
+ * business decision was answered): the steps build on the existing design and code
71
+ * instead of starting over. Absent in older state files.
72
+ */
73
+ incremental?: IncrementalInfo | null;
74
+ /**
75
+ * Set when the requirement is developed in a worktree created for it (--worktree):
76
+ * the Paseo workspace and the checkout the chat works in. `repo` is the worktree.
77
+ */
78
+ worktree?: { workspaceId: string; sourceRepo: string } | null;
79
+ /** Paseo schedules this run created (reminders, automatic continuation, watchdog); cancelled by the next run. */
80
+ schedules?: Array<{ id: string; kind: "reminder" | "auto-resume" | "watchdog" | "verify-start"; at: string | null }>;
81
+ /** Automatic continuations used in this attempt. */
82
+ autoResumes?: { attempt: number; count: number } | null;
83
+ /** Set while waiting for a free slot (maxConcurrentPipelines). */
84
+ queuedSince?: string | null;
85
+ /** Execution mode of this run; absent in older state files (continuous). */
86
+ phases?: RunPhases | null;
87
+ /** Current phase; absent in older state files. */
88
+ phase?: PhaseName;
89
+ /** --verify-at: when the verify phase should start once development is done (ISO time). */
90
+ verifyAt?: string | null;
91
+ /** How many times the verify phase handed defects back to development (defects "handback"). */
92
+ handbacks?: number;
93
+ }
94
+
95
+ export interface IncrementalInfo {
96
+ /** Attempt whose work this one continues. */
97
+ fromAttempt: number;
98
+ reason: string;
99
+ /** Commit at the start of this attempt; reviewers look at changes since here. Null outside git. */
100
+ baseCommit: string | null;
101
+ /** Whether the previous attempt got as far as writing code. */
102
+ hadImplementation: boolean;
103
+ }
104
+
105
+ export function pipelineDir(repo: string, featureDir: string): string {
106
+ return path.join(repo, featureDir, ".pipeline");
107
+ }
108
+
109
+ export function statePath(repo: string, featureDir: string): string {
110
+ return path.join(pipelineDir(repo, featureDir), "state.json");
111
+ }
112
+
113
+ export function ensurePipelineDir(repo: string, featureDir: string): string {
114
+ const dir = pipelineDir(repo, featureDir);
115
+ fs.mkdirSync(path.join(dir, "results"), { recursive: true });
116
+ const ignore = path.join(dir, ".gitignore");
117
+ // Runtime files only; never committed by the step agents.
118
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
119
+ return dir;
120
+ }
121
+
122
+ /**
123
+ * Format version of state.json written by this engine. Bump it when the shape changes
124
+ * and add a migration from the previous version to STATE_MIGRATIONS.
125
+ */
126
+ export const STATE_VERSION = 1;
127
+
128
+ /** Upgrades a state object from version `n` to `n + 1`. */
129
+ export type StateMigration = (state: Record<string, unknown>) => Record<string, unknown>;
130
+
131
+ /** Keyed by the version a migration upgrades from. */
132
+ export const STATE_MIGRATIONS: Record<number, StateMigration> = {};
133
+
134
+ export class StateVersionError extends Error {
135
+ constructor(file: string, found: number, supported: number) {
136
+ super(
137
+ `${file} 是由更新版本的 req-kit 写入的(格式第 ${found} 版,当前版本只支持到第 ${supported} 版)。` +
138
+ "请升级 req-kit 后再操作;如果是回滚到旧版本造成的,可以把同目录下的 state.json.bak-v<旧版本号> 恢复为 state.json," +
139
+ "或者删除 state.json 后用 --restart 从头开始。",
140
+ );
141
+ this.name = "StateVersionError";
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Brings a parsed state object up to `target`, one version at a time.
147
+ * Files written before the version field existed count as version 1.
148
+ */
149
+ export function migrateState(
150
+ raw: Record<string, unknown>,
151
+ file: string,
152
+ migrations: Record<number, StateMigration> = STATE_MIGRATIONS,
153
+ target: number = STATE_VERSION,
154
+ ): { state: Record<string, unknown>; from: number } {
155
+ const from = typeof raw.version === "number" ? raw.version : 1;
156
+ if (from > target) throw new StateVersionError(file, from, target);
157
+ let state = raw;
158
+ for (let version = from; version < target; version += 1) {
159
+ const step = migrations[version];
160
+ if (!step) throw new Error(`缺少 state.json 从第 ${version} 版到第 ${version + 1} 版的迁移`);
161
+ state = { ...step(state), version: version + 1 };
162
+ }
163
+ return { state, from };
164
+ }
165
+
166
+ /**
167
+ * Reads state.json, migrating an older format in place. The file as it was is kept
168
+ * next to it as state.json.bak-v<old version>, so a rollback can restore it.
169
+ */
170
+ export function readState(
171
+ file: string,
172
+ migrations: Record<number, StateMigration> = STATE_MIGRATIONS,
173
+ target: number = STATE_VERSION,
174
+ ): PipelineState | null {
175
+ if (!fs.existsSync(file)) return null;
176
+ const text = fs.readFileSync(file, "utf8");
177
+ const raw = JSON.parse(text) as Record<string, unknown>;
178
+ const { state, from } = migrateState(raw, file, migrations, target);
179
+ if (from < target) {
180
+ const backup = `${file}.bak-v${from}`;
181
+ if (!fs.existsSync(backup)) fs.writeFileSync(backup, text);
182
+ const tmp = `${file}.${process.pid}.tmp`;
183
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
184
+ fs.renameSync(tmp, file);
185
+ }
186
+ return state as unknown as PipelineState;
187
+ }
188
+
189
+ export function writeState(file: string, state: PipelineState): void {
190
+ state.updatedAt = new Date().toISOString();
191
+ const tmp = `${file}.${process.pid}.tmp`;
192
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
193
+ fs.renameSync(tmp, file);
194
+ }
195
+
196
+ export function hashFile(file: string): string {
197
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // One pipeline per repository checkout: the steps switch branches and commit,
202
+ // so two pipelines in the same directory would corrupt each other's work.
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export interface LockInfo {
206
+ pid: number;
207
+ featureDir: string;
208
+ startedAt: string;
209
+ }
210
+
211
+ export function lockPath(repo: string): string {
212
+ const id = crypto.createHash("sha1").update(fs.realpathSync(repo)).digest("hex").slice(0, 12);
213
+ return path.join(os.tmpdir(), `requirement-pipeline-${id}.lock`);
214
+ }
215
+
216
+ export function isAlive(pid: number): boolean {
217
+ try {
218
+ process.kill(pid, 0);
219
+ return true;
220
+ } catch (error) {
221
+ return (error as NodeJS.ErrnoException).code === "EPERM";
222
+ }
223
+ }
224
+
225
+ /** Returns the live holder of the repository lock, or null. Removes a stale lock. */
226
+ export function currentLockHolder(repo: string): LockInfo | null {
227
+ const file = lockPath(repo);
228
+ if (!fs.existsSync(file)) return null;
229
+ try {
230
+ const info = JSON.parse(fs.readFileSync(file, "utf8")) as LockInfo;
231
+ if (isAlive(info.pid)) return info;
232
+ } catch {
233
+ // Unreadable lock: treat as stale.
234
+ }
235
+ fs.rmSync(file, { force: true });
236
+ return null;
237
+ }
238
+
239
+ export function acquireLock(repo: string, featureDir: string): () => void {
240
+ const file = lockPath(repo);
241
+ const holder = currentLockHolder(repo);
242
+ if (holder && holder.pid !== process.pid) {
243
+ throw new LockHeldError(holder);
244
+ }
245
+ const info: LockInfo = { pid: process.pid, featureDir, startedAt: new Date().toISOString() };
246
+ try {
247
+ fs.writeFileSync(file, JSON.stringify(info), { flag: holder ? "w" : "wx" });
248
+ } catch (error) {
249
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
250
+ const other = currentLockHolder(repo);
251
+ if (other) throw new LockHeldError(other);
252
+ fs.writeFileSync(file, JSON.stringify(info), { flag: "wx" });
253
+ } else {
254
+ throw error;
255
+ }
256
+ }
257
+ let released = false;
258
+ return () => {
259
+ if (released) return;
260
+ released = true;
261
+ try {
262
+ const now = JSON.parse(fs.readFileSync(file, "utf8")) as LockInfo;
263
+ if (now.pid === process.pid) fs.rmSync(file, { force: true });
264
+ } catch {
265
+ // Already gone.
266
+ }
267
+ };
268
+ }
269
+
270
+ export class LockHeldError extends Error {
271
+ constructor(public readonly holder: LockInfo) {
272
+ super(
273
+ `这个仓库目录已有一条流水线在运行(进程 ${holder.pid},功能目录 ${holder.featureDir},开始于 ${holder.startedAt})。` +
274
+ "同一个目录一次只能运行一条,请等它结束。",
275
+ );
276
+ this.name = "LockHeldError";
277
+ }
278
+ }