paseo-req-kit 0.0.0-stage → 1.8.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.
- package/CHANGELOG.md +283 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/SCOPE.md +11 -0
- package/bin/locate.mjs +46 -0
- package/bin/req-doctor.mjs +21 -0
- package/bin/req.mjs +179 -0
- package/bin/start-pipeline +21 -0
- package/bin/start-pipeline.mjs +22 -0
- package/config.example.json +132 -0
- package/contracts/README.md +18 -0
- package/contracts/feature-files.md +117 -0
- package/contracts/notifier-event.md +72 -0
- package/contracts/result-file.md +21 -0
- package/contracts/spec.md +36 -0
- package/contracts/state.md +49 -0
- package/contracts/verify.md +41 -0
- package/docs/adr/0001-step-agents-under-origin-chat.md +15 -0
- package/docs/adr/0002-no-directory-switch-in-chat.md +15 -0
- package/docs/adr/0003-script-engine-not-hub.md +15 -0
- package/docs/adr/0004-result-files.md +15 -0
- package/docs/adr/0005-no-default-branch-local-commits-only.md +15 -0
- package/docs/adr/0006-skill-install-targets.md +15 -0
- package/docs/adr/0007-own-interview-skill.md +15 -0
- package/docs/adr/0008-entries-do-not-call-entries.md +15 -0
- package/docs/adr/0009-versioned-install-layout.md +15 -0
- package/docs/adr/0010-generated-feature-overview.md +15 -0
- package/docs/adr/0011-resume-after-completion.md +15 -0
- package/docs/adr/0012-review-panel.md +18 -0
- package/docs/adr/0013-no-release-tooling.md +15 -0
- package/docs/adr/0014-notifier-slot.md +19 -0
- package/docs/adr/0015-incremental-continue.md +17 -0
- package/docs/adr/0016-upgrade-from-tags.md +19 -0
- package/docs/adr/0017-use-paseo-capabilities.md +18 -0
- package/docs/adr/0018-own-worktree-option.md +21 -0
- package/docs/adr/0019-parallel-orchestration.md +21 -0
- package/docs/adr/0020-scheduling-through-the-chat.md +21 -0
- package/docs/adr/0021-npm-version-manager.md +19 -0
- package/docs/adr/0022-develop-and-verify-phases.md +32 -0
- package/docs/adr/0023-types-gate-slices.md +54 -0
- package/docs/adr/0024-confirm-early-review-axes.md +27 -0
- package/docs/adr/0025-joint-requirements.md +27 -0
- package/docs/adr/README.md +29 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +96 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +348 -0
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +85 -0
- 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 +356 -0
- package/engine/prompts/00-shared-context.md +104 -0
- package/engine/prompts/02-spec-review.md +46 -0
- package/engine/prompts/03-design.md +73 -0
- package/engine/prompts/03a-design-proposal.md +30 -0
- package/engine/prompts/04-design-review.md +73 -0
- package/engine/prompts/04b-design-revise.md +41 -0
- package/engine/prompts/05-implement.md +72 -0
- package/engine/prompts/06-code-review.md +70 -0
- package/engine/prompts/07-review-fix.md +41 -0
- package/engine/prompts/07b-handoff.md +45 -0
- package/engine/prompts/08-service-start.md +59 -0
- package/engine/prompts/09-verify.md +90 -0
- package/engine/prompts/10-defect-fix.md +41 -0
- package/engine/prompts/10b-reverify.md +43 -0
- package/engine/prompts/11-report.md +112 -0
- package/engine/prompts/browser-verify.md +13 -0
- package/engine/prompts/incremental.md +22 -0
- package/engine/prompts/joint.md +55 -0
- package/engine/prompts/panel-member.md +38 -0
- package/engine/prompts/paseo-services.md +9 -0
- package/engine/prompts/related-repos.md +7 -0
- package/engine/prompts/review-axis-spec.md +10 -0
- package/engine/prompts/review-axis-standards.md +11 -0
- package/engine/prompts/types/assessment.md +41 -0
- package/engine/prompts/types/defect.md +55 -0
- package/engine/prompts/types/refactor.md +39 -0
- package/engine/prompts/verify-deployed.md +14 -0
- package/engine/src/activity.ts +87 -0
- package/engine/src/cli-args.ts +217 -0
- package/engine/src/config.ts +619 -0
- package/engine/src/configure.ts +237 -0
- package/engine/src/control.ts +246 -0
- package/engine/src/daemon-check.ts +104 -0
- package/engine/src/daemon-version.ts +66 -0
- package/engine/src/digest.ts +142 -0
- package/engine/src/doctor.ts +462 -0
- package/engine/src/eval.ts +324 -0
- package/engine/src/features.ts +270 -0
- package/engine/src/gate.ts +157 -0
- package/engine/src/git.ts +30 -0
- package/engine/src/joint.ts +173 -0
- package/engine/src/launch.ts +405 -0
- package/engine/src/lessons.ts +72 -0
- package/engine/src/locations.ts +55 -0
- package/engine/src/main.ts +346 -0
- package/engine/src/messages.ts +394 -0
- package/engine/src/notifiers.ts +76 -0
- package/engine/src/notify-test.ts +101 -0
- package/engine/src/paseo-json.ts +73 -0
- package/engine/src/paseo-profiles.ts +133 -0
- package/engine/src/paseo-runtime.ts +437 -0
- package/engine/src/paths.ts +10 -0
- package/engine/src/pipeline.ts +1840 -0
- package/engine/src/playbooks.ts +46 -0
- package/engine/src/preflight.ts +337 -0
- package/engine/src/prompts.ts +72 -0
- package/engine/src/queue.ts +61 -0
- package/engine/src/runtime-loader.ts +14 -0
- package/engine/src/runtime.ts +155 -0
- package/engine/src/schedule-time.ts +51 -0
- package/engine/src/smoke-test.ts +202 -0
- package/engine/src/spec-review.ts +100 -0
- package/engine/src/state.ts +333 -0
- package/engine/src/steps.ts +266 -0
- package/engine/src/usage.ts +67 -0
- package/engine/src/worktree.ts +72 -0
- package/install.mjs +928 -0
- package/notifiers/im-template.mjs +60 -0
- package/package.json +67 -4
- package/principles/INDEX.md +11 -0
- package/skills/acceptance-criteria/SKILL.md +61 -0
- package/skills/principle-delete-before-add/SKILL.md +33 -0
- package/skills/principle-dont-block-on-humans/SKILL.md +35 -0
- package/skills/principle-fix-root-cause/SKILL.md +33 -0
- package/skills/principle-prove-it-works/SKILL.md +33 -0
- package/skills/principle-report-honestly/SKILL.md +33 -0
- package/skills/principle-speak-to-reader/SKILL.md +33 -0
- package/skills/principle-this-requirement-only/SKILL.md +33 -0
- package/skills/req/SKILL.md +101 -0
- package/skills/req/playbooks/accept.md +65 -0
- package/skills/req/playbooks/change.md +51 -0
- package/skills/req/playbooks/incident.md +55 -0
- package/skills/req/playbooks/new.md +94 -0
- package/skills/req/playbooks/small.md +48 -0
- package/skills/req/playbooks/status.md +41 -0
- package/skills/req/playbooks/takeover.md +51 -0
- package/skills/req/playbooks/verify.md +57 -0
- package/skills/req-doctor/SKILL.md +36 -0
- package/skills/req-retro/SKILL.md +81 -0
- package/skills/req-setup/SKILL.md +118 -0
- package/skills/req-setup/templates/paseo/copy-local-files.mjs +32 -0
- package/skills/req-setup/templates/paseo/with-port.mjs +36 -0
- package/skills/requirement-interview/SKILL.md +97 -0
- package/skills/requirement-kickoff/SKILL.md +17 -0
- package/skills/result-report/SKILL.md +34 -0
- package/skills/spec-writing/SKILL.md +55 -0
- package/skills/spec-writing/spec-template.md +109 -0
|
@@ -0,0 +1,333 @@
|
|
|
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, RequirementType, 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
|
+
* "waiting_for_approval" (1.8): the design waits for the requester's confirmation
|
|
14
|
+
* (designApproval); older engines continue as after an interruption, i.e. without it.
|
|
15
|
+
*/
|
|
16
|
+
export type PipelineStatus = "running" | "waiting_for_business" | "failed" | "completed" | "phase_done" | "waiting_for_approval";
|
|
17
|
+
|
|
18
|
+
/** Phase names are strings so later phases need no format change. */
|
|
19
|
+
export type PhaseName = "develop" | "verify";
|
|
20
|
+
|
|
21
|
+
/** Execution mode and verify-phase options a run settled on (absent before 1.7: continuous). */
|
|
22
|
+
export interface RunPhases {
|
|
23
|
+
mode: PhaseMode;
|
|
24
|
+
environment: VerifyEnvironment;
|
|
25
|
+
defects: DefectHandling;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StepRecord {
|
|
29
|
+
key: string;
|
|
30
|
+
stepId: StepId;
|
|
31
|
+
round: number | null;
|
|
32
|
+
attempt: number;
|
|
33
|
+
agentId: string;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
endedAt: string | null;
|
|
36
|
+
outcome: "running" | "completed" | "failed";
|
|
37
|
+
followUps: number;
|
|
38
|
+
result: Record<string, unknown> | null;
|
|
39
|
+
error: string | null;
|
|
40
|
+
/** Latest usage the agent reported for this step; absent in older state files. */
|
|
41
|
+
usage?: AgentUsage | null;
|
|
42
|
+
/** Phase the step ran in; absent before 1.7 (develop or verify by step). */
|
|
43
|
+
phase?: PhaseName;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PipelineState {
|
|
47
|
+
/** Format version, see STATE_VERSION. */
|
|
48
|
+
version: number;
|
|
49
|
+
repo: string;
|
|
50
|
+
featureDir: string;
|
|
51
|
+
status: PipelineStatus;
|
|
52
|
+
/** Incremented every time the pipeline starts over from the design step. */
|
|
53
|
+
attempt: number;
|
|
54
|
+
specHash: string;
|
|
55
|
+
originAgentId: string | null;
|
|
56
|
+
pid: number;
|
|
57
|
+
startedAt: string;
|
|
58
|
+
updatedAt: string;
|
|
59
|
+
/** Completed step results by step key. A resumed run reuses these instead of re-running the step. */
|
|
60
|
+
results: Record<string, Record<string, unknown>>;
|
|
61
|
+
history: StepRecord[];
|
|
62
|
+
stopReason: string | null;
|
|
63
|
+
outcome: string | null;
|
|
64
|
+
/** "quick" or "standard"; decides loop rounds and optional steps. */
|
|
65
|
+
profile: ProfileName;
|
|
66
|
+
/** Git branch the run commits to, fixed at launch. Null outside a git repository. */
|
|
67
|
+
branch: string | null;
|
|
68
|
+
/** Progress notes already sent, so a resumed run does not repeat them. */
|
|
69
|
+
milestones: string[];
|
|
70
|
+
/**
|
|
71
|
+
* Set when this attempt restarted from design because the requirement changed (or a
|
|
72
|
+
* business decision was answered): the steps build on the existing design and code
|
|
73
|
+
* instead of starting over. Absent in older state files.
|
|
74
|
+
*/
|
|
75
|
+
incremental?: IncrementalInfo | null;
|
|
76
|
+
/**
|
|
77
|
+
* Set when the requirement is developed in a worktree created for it (--worktree):
|
|
78
|
+
* the Paseo workspace and the checkout the chat works in. `repo` is the worktree.
|
|
79
|
+
*/
|
|
80
|
+
worktree?: { workspaceId: string; sourceRepo: string } | null;
|
|
81
|
+
/** Paseo schedules this run created (reminders, automatic continuation, watchdog); cancelled by the next run. */
|
|
82
|
+
schedules?: Array<{ id: string; kind: "reminder" | "auto-resume" | "watchdog" | "verify-start" | "approval-continue"; at: string | null }>;
|
|
83
|
+
/** Automatic continuations used in this attempt. */
|
|
84
|
+
autoResumes?: { attempt: number; count: number } | null;
|
|
85
|
+
/** Set while waiting for a free slot (maxConcurrentPipelines). */
|
|
86
|
+
queuedSince?: string | null;
|
|
87
|
+
/** Execution mode of this run; absent in older state files (continuous). */
|
|
88
|
+
phases?: RunPhases | null;
|
|
89
|
+
/** Current phase; absent in older state files. */
|
|
90
|
+
phase?: PhaseName;
|
|
91
|
+
/** --verify-at: when the verify phase should start once development is done (ISO time). */
|
|
92
|
+
verifyAt?: string | null;
|
|
93
|
+
/** How many times the verify phase handed defects back to development (defects "handback"). */
|
|
94
|
+
handbacks?: number;
|
|
95
|
+
/** Requirement type with its own playbook, from the spec's "类型" line; absent: the normal flow (1.8). */
|
|
96
|
+
type?: RequirementType | null;
|
|
97
|
+
/** Commit before the attempt's first code change; the quality gate's baseline and the scope check start here (1.8). */
|
|
98
|
+
codeBase?: { attempt: number; commit: string | null } | null;
|
|
99
|
+
/**
|
|
100
|
+
* Joint requirement (1.8): other repositories this requirement also changes, each in a
|
|
101
|
+
* worktree created for it, with its own code base and quality gate. See ADR 0025.
|
|
102
|
+
*/
|
|
103
|
+
joint?: JointRepo[] | null;
|
|
104
|
+
/** Design confirmation point (1.8): which attempt's design is waiting or was confirmed. */
|
|
105
|
+
approval?: { attempt: number; status: "waiting" | "approved"; at: string; auto?: boolean } | null;
|
|
106
|
+
/** Quality gate: the checks' results before the change, and every run after a step (1.8). */
|
|
107
|
+
gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface JointRepo {
|
|
111
|
+
/** Name in relatedRepos, e.g. "后端". */
|
|
112
|
+
name: string;
|
|
113
|
+
/** ASCII key suffix for its steps ("implement@backend"). */
|
|
114
|
+
slug: string;
|
|
115
|
+
/** The checkout the worktree was cut from. */
|
|
116
|
+
sourceRepo: string;
|
|
117
|
+
/** The worktree the steps work in. */
|
|
118
|
+
repo: string;
|
|
119
|
+
workspaceId: string;
|
|
120
|
+
branch: string;
|
|
121
|
+
/** Hash of the spec last copied there. */
|
|
122
|
+
specHash: string | null;
|
|
123
|
+
codeBase?: { attempt: number; commit: string | null } | null;
|
|
124
|
+
gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type CheckOutcome = "pass" | "fail";
|
|
128
|
+
|
|
129
|
+
export interface GateBaseline {
|
|
130
|
+
attempt: number;
|
|
131
|
+
commit: string | null;
|
|
132
|
+
at: string;
|
|
133
|
+
results: Record<string, CheckOutcome>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface GateRun {
|
|
137
|
+
/** Step key the checks ran after, e.g. "implement-s2", "review_fix-r1". */
|
|
138
|
+
key: string;
|
|
139
|
+
attempt: number;
|
|
140
|
+
at: string;
|
|
141
|
+
/** Checks that applied to the change; "skipped" ones did not touch their paths. */
|
|
142
|
+
checks: Array<{ name: string; result: CheckOutcome | "skipped"; failedBefore: boolean }>;
|
|
143
|
+
/** Checks that pass before the change and fail after it: these block. */
|
|
144
|
+
newFailures: string[];
|
|
145
|
+
/** Times the step agent was asked to fix them. */
|
|
146
|
+
fixRounds: number;
|
|
147
|
+
passed: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface IncrementalInfo {
|
|
151
|
+
/** Attempt whose work this one continues. */
|
|
152
|
+
fromAttempt: number;
|
|
153
|
+
reason: string;
|
|
154
|
+
/** Commit at the start of this attempt; reviewers look at changes since here. Null outside git. */
|
|
155
|
+
baseCommit: string | null;
|
|
156
|
+
/** Whether the previous attempt got as far as writing code. */
|
|
157
|
+
hadImplementation: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function pipelineDir(repo: string, featureDir: string): string {
|
|
161
|
+
return path.join(repo, featureDir, ".pipeline");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function statePath(repo: string, featureDir: string): string {
|
|
165
|
+
return path.join(pipelineDir(repo, featureDir), "state.json");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function ensurePipelineDir(repo: string, featureDir: string): string {
|
|
169
|
+
const dir = pipelineDir(repo, featureDir);
|
|
170
|
+
fs.mkdirSync(path.join(dir, "results"), { recursive: true });
|
|
171
|
+
const ignore = path.join(dir, ".gitignore");
|
|
172
|
+
// Runtime files only; never committed by the step agents.
|
|
173
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
|
|
174
|
+
return dir;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Format version of state.json written by this engine. Bump it when the shape changes
|
|
179
|
+
* and add a migration from the previous version to STATE_MIGRATIONS.
|
|
180
|
+
*/
|
|
181
|
+
export const STATE_VERSION = 1;
|
|
182
|
+
|
|
183
|
+
/** Upgrades a state object from version `n` to `n + 1`. */
|
|
184
|
+
export type StateMigration = (state: Record<string, unknown>) => Record<string, unknown>;
|
|
185
|
+
|
|
186
|
+
/** Keyed by the version a migration upgrades from. */
|
|
187
|
+
export const STATE_MIGRATIONS: Record<number, StateMigration> = {};
|
|
188
|
+
|
|
189
|
+
export class StateVersionError extends Error {
|
|
190
|
+
constructor(file: string, found: number, supported: number) {
|
|
191
|
+
super(
|
|
192
|
+
`${file} 是由更新版本的 req-kit 写入的(格式第 ${found} 版,当前版本只支持到第 ${supported} 版)。` +
|
|
193
|
+
"请升级 req-kit 后再操作;如果是回滚到旧版本造成的,可以把同目录下的 state.json.bak-v<旧版本号> 恢复为 state.json," +
|
|
194
|
+
"或者删除 state.json 后用 --restart 从头开始。",
|
|
195
|
+
);
|
|
196
|
+
this.name = "StateVersionError";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Brings a parsed state object up to `target`, one version at a time.
|
|
202
|
+
* Files written before the version field existed count as version 1.
|
|
203
|
+
*/
|
|
204
|
+
export function migrateState(
|
|
205
|
+
raw: Record<string, unknown>,
|
|
206
|
+
file: string,
|
|
207
|
+
migrations: Record<number, StateMigration> = STATE_MIGRATIONS,
|
|
208
|
+
target: number = STATE_VERSION,
|
|
209
|
+
): { state: Record<string, unknown>; from: number } {
|
|
210
|
+
const from = typeof raw.version === "number" ? raw.version : 1;
|
|
211
|
+
if (from > target) throw new StateVersionError(file, from, target);
|
|
212
|
+
let state = raw;
|
|
213
|
+
for (let version = from; version < target; version += 1) {
|
|
214
|
+
const step = migrations[version];
|
|
215
|
+
if (!step) throw new Error(`缺少 state.json 从第 ${version} 版到第 ${version + 1} 版的迁移`);
|
|
216
|
+
state = { ...step(state), version: version + 1 };
|
|
217
|
+
}
|
|
218
|
+
return { state, from };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reads state.json, migrating an older format in place. The file as it was is kept
|
|
223
|
+
* next to it as state.json.bak-v<old version>, so a rollback can restore it.
|
|
224
|
+
*/
|
|
225
|
+
export function readState(
|
|
226
|
+
file: string,
|
|
227
|
+
migrations: Record<number, StateMigration> = STATE_MIGRATIONS,
|
|
228
|
+
target: number = STATE_VERSION,
|
|
229
|
+
): PipelineState | null {
|
|
230
|
+
if (!fs.existsSync(file)) return null;
|
|
231
|
+
const text = fs.readFileSync(file, "utf8");
|
|
232
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
233
|
+
const { state, from } = migrateState(raw, file, migrations, target);
|
|
234
|
+
if (from < target) {
|
|
235
|
+
const backup = `${file}.bak-v${from}`;
|
|
236
|
+
if (!fs.existsSync(backup)) fs.writeFileSync(backup, text);
|
|
237
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
238
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
239
|
+
fs.renameSync(tmp, file);
|
|
240
|
+
}
|
|
241
|
+
return state as unknown as PipelineState;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function writeState(file: string, state: PipelineState): void {
|
|
245
|
+
state.updatedAt = new Date().toISOString();
|
|
246
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
247
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
248
|
+
fs.renameSync(tmp, file);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function hashFile(file: string): string {
|
|
252
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// One pipeline per repository checkout: the steps switch branches and commit,
|
|
257
|
+
// so two pipelines in the same directory would corrupt each other's work.
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export interface LockInfo {
|
|
261
|
+
pid: number;
|
|
262
|
+
featureDir: string;
|
|
263
|
+
startedAt: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function lockPath(repo: string): string {
|
|
267
|
+
const id = crypto.createHash("sha1").update(fs.realpathSync(repo)).digest("hex").slice(0, 12);
|
|
268
|
+
return path.join(os.tmpdir(), `requirement-pipeline-${id}.lock`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function isAlive(pid: number): boolean {
|
|
272
|
+
try {
|
|
273
|
+
process.kill(pid, 0);
|
|
274
|
+
return true;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Returns the live holder of the repository lock, or null. Removes a stale lock. */
|
|
281
|
+
export function currentLockHolder(repo: string): LockInfo | null {
|
|
282
|
+
const file = lockPath(repo);
|
|
283
|
+
if (!fs.existsSync(file)) return null;
|
|
284
|
+
try {
|
|
285
|
+
const info = JSON.parse(fs.readFileSync(file, "utf8")) as LockInfo;
|
|
286
|
+
if (isAlive(info.pid)) return info;
|
|
287
|
+
} catch {
|
|
288
|
+
// Unreadable lock: treat as stale.
|
|
289
|
+
}
|
|
290
|
+
fs.rmSync(file, { force: true });
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function acquireLock(repo: string, featureDir: string): () => void {
|
|
295
|
+
const file = lockPath(repo);
|
|
296
|
+
const holder = currentLockHolder(repo);
|
|
297
|
+
if (holder && holder.pid !== process.pid) {
|
|
298
|
+
throw new LockHeldError(holder);
|
|
299
|
+
}
|
|
300
|
+
const info: LockInfo = { pid: process.pid, featureDir, startedAt: new Date().toISOString() };
|
|
301
|
+
try {
|
|
302
|
+
fs.writeFileSync(file, JSON.stringify(info), { flag: holder ? "w" : "wx" });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
305
|
+
const other = currentLockHolder(repo);
|
|
306
|
+
if (other) throw new LockHeldError(other);
|
|
307
|
+
fs.writeFileSync(file, JSON.stringify(info), { flag: "wx" });
|
|
308
|
+
} else {
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
let released = false;
|
|
313
|
+
return () => {
|
|
314
|
+
if (released) return;
|
|
315
|
+
released = true;
|
|
316
|
+
try {
|
|
317
|
+
const now = JSON.parse(fs.readFileSync(file, "utf8")) as LockInfo;
|
|
318
|
+
if (now.pid === process.pid) fs.rmSync(file, { force: true });
|
|
319
|
+
} catch {
|
|
320
|
+
// Already gone.
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export class LockHeldError extends Error {
|
|
326
|
+
constructor(public readonly holder: LockInfo) {
|
|
327
|
+
super(
|
|
328
|
+
`这个仓库目录已有一条流水线在运行(进程 ${holder.pid},功能目录 ${holder.featureDir},开始于 ${holder.startedAt})。` +
|
|
329
|
+
"同一个目录一次只能运行一条,请等它结束。",
|
|
330
|
+
);
|
|
331
|
+
this.name = "LockHeldError";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { AgentRole } from "./runtime.js";
|
|
2
|
+
|
|
3
|
+
export type StepId =
|
|
4
|
+
| "design"
|
|
5
|
+
| "design_review"
|
|
6
|
+
| "design_revise"
|
|
7
|
+
| "implement"
|
|
8
|
+
| "code_review"
|
|
9
|
+
| "review_fix"
|
|
10
|
+
| "service_start"
|
|
11
|
+
| "verify"
|
|
12
|
+
| "defect_fix"
|
|
13
|
+
| "reverify"
|
|
14
|
+
| "report"
|
|
15
|
+
/** Handoff note for testing (提测说明), the last step of the develop phase. */
|
|
16
|
+
| "handoff"
|
|
17
|
+
/** One member of the optional review panel; runs before design_review / code_review consolidates. */
|
|
18
|
+
| "panel_review"
|
|
19
|
+
/** Independent review of a draft spec before the requester confirms it (run from the chat, not the pipeline). */
|
|
20
|
+
| "spec_review"
|
|
21
|
+
/** One of the parallel design proposals that the design step compares (optional). */
|
|
22
|
+
| "design_proposal";
|
|
23
|
+
|
|
24
|
+
type FieldSpec =
|
|
25
|
+
| { kind: "boolean" }
|
|
26
|
+
| { kind: "enum"; values: readonly string[] }
|
|
27
|
+
| { kind: "string" }
|
|
28
|
+
| { kind: "string[]" };
|
|
29
|
+
|
|
30
|
+
export interface ResultSpec {
|
|
31
|
+
required: Record<string, FieldSpec>;
|
|
32
|
+
optional?: Record<string, FieldSpec>;
|
|
33
|
+
/** Extra cross-field rule; returns an error message or null. */
|
|
34
|
+
check?: (value: Record<string, unknown>) => string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StepDef {
|
|
38
|
+
id: StepId;
|
|
39
|
+
/** Step number in the 11-step list the user defined. */
|
|
40
|
+
number: number;
|
|
41
|
+
title: string;
|
|
42
|
+
role: AgentRole;
|
|
43
|
+
promptFile: string;
|
|
44
|
+
result: ResultSpec;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const done: ResultSpec = { required: { status: { kind: "enum", values: ["done", "partial"] } } };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Optional plan fields of the design result (1.8): the implementation slices, the files the
|
|
51
|
+
* plan expects to change (for the scope check), and for a defect whether it was reproduced.
|
|
52
|
+
*/
|
|
53
|
+
const PLAN_FIELDS: Pick<ResultSpec, "optional"> = {
|
|
54
|
+
optional: {
|
|
55
|
+
slices: { kind: "string[]" },
|
|
56
|
+
files: { kind: "string[]" },
|
|
57
|
+
reproduced: { kind: "boolean" },
|
|
58
|
+
questions: { kind: "string[]" },
|
|
59
|
+
ui_change: { kind: "boolean" },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Review steps that can run with a review panel. */
|
|
64
|
+
export const PANEL_STEPS = ["design_review", "code_review"] as const;
|
|
65
|
+
export type PanelStep = (typeof PANEL_STEPS)[number];
|
|
66
|
+
|
|
67
|
+
export const STEPS: Record<StepId, StepDef> = {
|
|
68
|
+
spec_review: {
|
|
69
|
+
id: "spec_review",
|
|
70
|
+
number: 2,
|
|
71
|
+
title: "需求说明审阅",
|
|
72
|
+
role: "reviewer",
|
|
73
|
+
promptFile: "02-spec-review.md",
|
|
74
|
+
result: {
|
|
75
|
+
required: { verdict: { kind: "enum", values: ["ready", "issues"] } },
|
|
76
|
+
optional: { issues: { kind: "string[]" } },
|
|
77
|
+
check: (value) =>
|
|
78
|
+
value.verdict === "issues" && (!Array.isArray(value.issues) || value.issues.length === 0)
|
|
79
|
+
? "verdict 为 issues 时,issues 必须是非空的字符串数组(每条一句话)"
|
|
80
|
+
: null,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
design_proposal: {
|
|
84
|
+
id: "design_proposal",
|
|
85
|
+
number: 3,
|
|
86
|
+
title: "方案提案",
|
|
87
|
+
role: "builder",
|
|
88
|
+
promptFile: "03a-design-proposal.md",
|
|
89
|
+
result: done,
|
|
90
|
+
},
|
|
91
|
+
panel_review: {
|
|
92
|
+
id: "panel_review",
|
|
93
|
+
number: 0,
|
|
94
|
+
title: "评审小组成员",
|
|
95
|
+
role: "reviewer",
|
|
96
|
+
promptFile: "panel-member.md",
|
|
97
|
+
result: done,
|
|
98
|
+
},
|
|
99
|
+
design: {
|
|
100
|
+
id: "design",
|
|
101
|
+
number: 3,
|
|
102
|
+
title: "方案设计",
|
|
103
|
+
role: "builder",
|
|
104
|
+
promptFile: "03-design.md",
|
|
105
|
+
result: {
|
|
106
|
+
required: { has_runnable_service: { kind: "boolean" } },
|
|
107
|
+
optional: PLAN_FIELDS.optional,
|
|
108
|
+
check: (value) =>
|
|
109
|
+
value.reproduced === false && (!Array.isArray(value.questions) || value.questions.length === 0)
|
|
110
|
+
? "reproduced 为 false 时,questions 必须是非空的字符串数组:需要需求方补充什么才能复现"
|
|
111
|
+
: null,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
design_review: {
|
|
115
|
+
id: "design_review",
|
|
116
|
+
number: 4,
|
|
117
|
+
title: "方案审核",
|
|
118
|
+
role: "reviewer",
|
|
119
|
+
promptFile: "04-design-review.md",
|
|
120
|
+
result: {
|
|
121
|
+
required: { verdict: { kind: "enum", values: ["approved", "changes_required", "blocked"] } },
|
|
122
|
+
optional: { questions: { kind: "string[]" } },
|
|
123
|
+
check: (value) =>
|
|
124
|
+
value.verdict === "blocked" && (!Array.isArray(value.questions) || value.questions.length === 0)
|
|
125
|
+
? "verdict 为 blocked 时,questions 必须是非空的字符串数组"
|
|
126
|
+
: null,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
design_revise: {
|
|
130
|
+
id: "design_revise",
|
|
131
|
+
number: 4,
|
|
132
|
+
title: "方案修订",
|
|
133
|
+
role: "builder",
|
|
134
|
+
promptFile: "04b-design-revise.md",
|
|
135
|
+
result: { required: done.required, optional: { slices: { kind: "string[]" }, files: { kind: "string[]" }, ui_change: { kind: "boolean" } } },
|
|
136
|
+
},
|
|
137
|
+
implement: {
|
|
138
|
+
id: "implement",
|
|
139
|
+
number: 5,
|
|
140
|
+
title: "代码实现",
|
|
141
|
+
role: "builder",
|
|
142
|
+
promptFile: "05-implement.md",
|
|
143
|
+
// acceptance_tests: test files written from the AC examples, committed first (in
|
|
144
|
+
// acceptance_tests_commit); the engine tells the code review when they changed later.
|
|
145
|
+
result: { required: done.required, optional: { acceptance_tests: { kind: "string[]" }, acceptance_tests_commit: { kind: "string" } } },
|
|
146
|
+
},
|
|
147
|
+
code_review: {
|
|
148
|
+
id: "code_review",
|
|
149
|
+
number: 6,
|
|
150
|
+
title: "代码评审",
|
|
151
|
+
role: "reviewer",
|
|
152
|
+
promptFile: "06-code-review.md",
|
|
153
|
+
result: { required: { verdict: { kind: "enum", values: ["pass", "changes_required"] } } },
|
|
154
|
+
},
|
|
155
|
+
review_fix: {
|
|
156
|
+
id: "review_fix",
|
|
157
|
+
number: 7,
|
|
158
|
+
title: "评审问题修复",
|
|
159
|
+
role: "builder",
|
|
160
|
+
promptFile: "07-review-fix.md",
|
|
161
|
+
result: done,
|
|
162
|
+
},
|
|
163
|
+
handoff: {
|
|
164
|
+
id: "handoff",
|
|
165
|
+
number: 7,
|
|
166
|
+
title: "提测说明",
|
|
167
|
+
role: "builder",
|
|
168
|
+
promptFile: "07b-handoff.md",
|
|
169
|
+
result: done,
|
|
170
|
+
},
|
|
171
|
+
service_start: {
|
|
172
|
+
id: "service_start",
|
|
173
|
+
number: 8,
|
|
174
|
+
title: "服务启动验证",
|
|
175
|
+
role: "builder",
|
|
176
|
+
promptFile: "08-service-start.md",
|
|
177
|
+
result: { required: { started: { kind: "boolean" } } },
|
|
178
|
+
},
|
|
179
|
+
verify: {
|
|
180
|
+
id: "verify",
|
|
181
|
+
number: 9,
|
|
182
|
+
title: "功能验证",
|
|
183
|
+
role: "reviewer",
|
|
184
|
+
promptFile: "09-verify.md",
|
|
185
|
+
// defect_repos (joint requirements): where the defects are, "本仓库" or a joint repository's name.
|
|
186
|
+
result: { required: { result: { kind: "enum", values: ["pass", "defects_found", "blocked"] } }, optional: { defect_repos: { kind: "string[]" } } },
|
|
187
|
+
},
|
|
188
|
+
defect_fix: {
|
|
189
|
+
id: "defect_fix",
|
|
190
|
+
number: 10,
|
|
191
|
+
title: "缺陷修复",
|
|
192
|
+
role: "builder",
|
|
193
|
+
promptFile: "10-defect-fix.md",
|
|
194
|
+
result: done,
|
|
195
|
+
},
|
|
196
|
+
reverify: {
|
|
197
|
+
id: "reverify",
|
|
198
|
+
number: 10,
|
|
199
|
+
title: "复验",
|
|
200
|
+
role: "reviewer",
|
|
201
|
+
promptFile: "10b-reverify.md",
|
|
202
|
+
result: { required: { result: { kind: "enum", values: ["pass", "defects_remaining"] } }, optional: { defect_repos: { kind: "string[]" } } },
|
|
203
|
+
},
|
|
204
|
+
report: {
|
|
205
|
+
id: "report",
|
|
206
|
+
number: 11,
|
|
207
|
+
title: "完成报告",
|
|
208
|
+
role: "builder",
|
|
209
|
+
promptFile: "11-report.md",
|
|
210
|
+
result: {
|
|
211
|
+
required: {
|
|
212
|
+
outcome: { kind: "enum", values: ["completed", "completed_with_issues", "not_completed"] },
|
|
213
|
+
summary: { kind: "string" },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function checkField(name: string, spec: FieldSpec, value: unknown): string | null {
|
|
220
|
+
switch (spec.kind) {
|
|
221
|
+
case "boolean":
|
|
222
|
+
return typeof value === "boolean" ? null : `${name} 必须是 true 或 false`;
|
|
223
|
+
case "string":
|
|
224
|
+
return typeof value === "string" && value.trim() !== "" ? null : `${name} 必须是非空字符串`;
|
|
225
|
+
case "string[]":
|
|
226
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
227
|
+
? null
|
|
228
|
+
: `${name} 必须是字符串数组`;
|
|
229
|
+
case "enum":
|
|
230
|
+
return typeof value === "string" && spec.values.includes(value)
|
|
231
|
+
? null
|
|
232
|
+
: `${name} 必须是 ${spec.values.map((v) => `"${v}"`).join(" / ")} 之一`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type ValidationResult =
|
|
237
|
+
| { ok: true; value: Record<string, unknown> }
|
|
238
|
+
| { ok: false; error: string };
|
|
239
|
+
|
|
240
|
+
export function validateResult(spec: ResultSpec, raw: unknown): ValidationResult {
|
|
241
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
242
|
+
return { ok: false, error: "内容必须是一个 JSON 对象" };
|
|
243
|
+
}
|
|
244
|
+
const value = raw as Record<string, unknown>;
|
|
245
|
+
for (const [name, field] of Object.entries(spec.required)) {
|
|
246
|
+
if (!(name in value)) return { ok: false, error: `缺少字段 ${name}` };
|
|
247
|
+
const error = checkField(name, field, value[name]);
|
|
248
|
+
if (error) return { ok: false, error };
|
|
249
|
+
}
|
|
250
|
+
for (const [name, field] of Object.entries(spec.optional ?? {})) {
|
|
251
|
+
if (name in value && value[name] !== undefined) {
|
|
252
|
+
const error = checkField(name, field, value[name]);
|
|
253
|
+
if (error) return { ok: false, error };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const crossError = spec.check?.(value) ?? null;
|
|
257
|
+
if (crossError) return { ok: false, error: crossError };
|
|
258
|
+
return { ok: true, value };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Steps of the verify phase; they may use phases.verify.agents. */
|
|
262
|
+
export const VERIFY_PHASE_STEPS: readonly StepId[] = ["service_start", "verify", "defect_fix", "reverify"];
|
|
263
|
+
|
|
264
|
+
export function stepKey(id: StepId, round: number | null): string {
|
|
265
|
+
return round === null ? id : `${id}-r${round}`;
|
|
266
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Time and token usage per step, as reported by the agents, and plain-language summaries.
|
|
2
|
+
|
|
3
|
+
export interface AgentUsage {
|
|
4
|
+
inputTokens?: number;
|
|
5
|
+
cachedInputTokens?: number;
|
|
6
|
+
outputTokens?: number;
|
|
7
|
+
totalCostUsd?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UsageRecord {
|
|
11
|
+
attempt: number;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
endedAt: string | null;
|
|
14
|
+
usage?: AgentUsage | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UsageTotals {
|
|
18
|
+
steps: number;
|
|
19
|
+
/** Sum of step durations (steps of a review panel overlap, so this can exceed wall time). */
|
|
20
|
+
agentMinutes: number;
|
|
21
|
+
inputTokens: number | null;
|
|
22
|
+
outputTokens: number | null;
|
|
23
|
+
costUsd: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function add(total: number | null, value: number | undefined): number | null {
|
|
27
|
+
return typeof value === "number" ? (total ?? 0) + value : total;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function totals(records: UsageRecord[], now = new Date()): UsageTotals {
|
|
31
|
+
let agentMinutes = 0;
|
|
32
|
+
let inputTokens: number | null = null;
|
|
33
|
+
let outputTokens: number | null = null;
|
|
34
|
+
let costUsd: number | null = null;
|
|
35
|
+
for (const r of records) {
|
|
36
|
+
const end = r.endedAt ? new Date(r.endedAt) : now;
|
|
37
|
+
agentMinutes += Math.max(0, (end.getTime() - new Date(r.startedAt).getTime()) / 60_000);
|
|
38
|
+
inputTokens = add(inputTokens, r.usage?.inputTokens);
|
|
39
|
+
outputTokens = add(outputTokens, r.usage?.outputTokens);
|
|
40
|
+
costUsd = add(costUsd, r.usage?.totalCostUsd);
|
|
41
|
+
}
|
|
42
|
+
return { steps: records.length, agentMinutes, inputTokens, outputTokens, costUsd };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tokens(n: number): string {
|
|
46
|
+
return n >= 10_000 ? `${(n / 10_000).toFixed(1).replace(/\.0$/, "")} 万` : String(n);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function minutes(m: number): string {
|
|
50
|
+
if (m < 1) return "不到 1 分钟";
|
|
51
|
+
if (m < 60) return `${Math.round(m)} 分钟`;
|
|
52
|
+
const h = Math.floor(m / 60);
|
|
53
|
+
const rest = Math.round(m % 60);
|
|
54
|
+
return rest === 0 ? `${h} 小时` : `${h} 小时 ${rest} 分`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** e.g. "6 个步骤,agent 合计用时 2 小时 5 分;输入 120 万 tokens,输出 8.3 万 tokens,约 $4.20" */
|
|
58
|
+
export function describeTotals(t: UsageTotals): string {
|
|
59
|
+
const parts = [`${t.steps} 个步骤,agent 合计用时 ${minutes(t.agentMinutes)}`];
|
|
60
|
+
const usage: string[] = [];
|
|
61
|
+
if (t.inputTokens !== null) usage.push(`输入 ${tokens(t.inputTokens)} tokens`);
|
|
62
|
+
if (t.outputTokens !== null) usage.push(`输出 ${tokens(t.outputTokens)} tokens`);
|
|
63
|
+
if (t.costUsd !== null) usage.push(`约 $${t.costUsd.toFixed(2)}`);
|
|
64
|
+
if (usage.length > 0) parts.push(usage.join(","));
|
|
65
|
+
else parts.push("agent 没有报告 token 用量");
|
|
66
|
+
return parts.join(";");
|
|
67
|
+
}
|