@openprd/cli 0.1.0
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/.openprd/README.md +82 -0
- package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +14 -0
- package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +14 -0
- package/.openprd/benchmarks/index.md +37 -0
- package/.openprd/benchmarks/sources.yaml +56 -0
- package/.openprd/config.yaml +50 -0
- package/.openprd/discovery/config.json +21 -0
- package/.openprd/engagements/active/flows.md +30 -0
- package/.openprd/engagements/active/handoff.md +9 -0
- package/.openprd/engagements/active/intake.md +15 -0
- package/.openprd/engagements/active/prd.md +161 -0
- package/.openprd/engagements/active/review.html +61 -0
- package/.openprd/engagements/active/roles.md +21 -0
- package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +23 -0
- package/.openprd/exports/.gitkeep +0 -0
- package/.openprd/knowledge/index.json +7 -0
- package/.openprd/quality/config.json +229 -0
- package/.openprd/reviews/v0001.html +1256 -0
- package/.openprd/schema/diagram-architecture.schema.yaml +49 -0
- package/.openprd/schema/diagram-product-flow.schema.yaml +52 -0
- package/.openprd/schema/prd.schema.yaml +121 -0
- package/.openprd/sessions/.gitkeep +0 -0
- package/.openprd/standards/config.json +88 -0
- package/.openprd/standards/file-manual-template.md +28 -0
- package/.openprd/standards/folder-readme-template.md +28 -0
- package/.openprd/state/.gitkeep +0 -0
- package/.openprd/state/changes.json +12 -0
- package/.openprd/state/current.json +169 -0
- package/.openprd/state/version-index.json +15 -0
- package/.openprd/state/versions/.gitkeep +0 -0
- package/.openprd/state/versions/v0001.json +121 -0
- package/.openprd/state/versions/v0001.md +161 -0
- package/.openprd/templates/agent/intake.md +6 -0
- package/.openprd/templates/agent/prd.md +21 -0
- package/.openprd/templates/b2b/intake.md +6 -0
- package/.openprd/templates/b2b/prd.md +24 -0
- package/.openprd/templates/base/intake.md +18 -0
- package/.openprd/templates/base/prd.md +67 -0
- package/.openprd/templates/company/README.md +10 -0
- package/.openprd/templates/consumer/intake.md +6 -0
- package/.openprd/templates/consumer/prd.md +19 -0
- package/.openprd/templates/diagram/architecture.contract.json +53 -0
- package/.openprd/templates/diagram/product-flow.contract.json +76 -0
- package/.openprd/templates/industry/README.md +16 -0
- package/.openprd/templates/manifest.yaml +27 -0
- package/.openprd/templates/project/README.md +14 -0
- package/.openprd/templates/session/README.md +14 -0
- package/AGENTS.md +44 -0
- package/CONTRIBUTING.md +30 -0
- package/LICENSE +21 -0
- package/README.md +727 -0
- package/README_CN.md +583 -0
- package/SECURITY.md +23 -0
- package/bin/openprd.js +5 -0
- package/docs/assets/openprd-capability-overview-en.png +0 -0
- package/docs/assets/openprd-capability-overview-zh.png +0 -0
- package/docs/assets/openprd-learning-html.png +0 -0
- package/docs/assets/openprd-quality-html.png +0 -0
- package/docs/assets/openprd-review-html.png +0 -0
- package/docs/assets/openprd-scenario-overview.png +0 -0
- package/docs/assets/openprd-scenario-overview.svg +114 -0
- package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
- package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
- package/package.json +59 -0
- package/scripts/openprd-dev-check.mjs +5 -0
- package/scripts/openprd-review-presentation.mjs +82 -0
- package/skills/openprd-benchmark-router/SKILL.md +92 -0
- package/skills/openprd-benchmark-router/agents/openai.yaml +4 -0
- package/skills/openprd-benchmark-router/references/benchmark-sources.md +74 -0
- package/skills/openprd-benchmark-router/references/evaluation-lenses.md +66 -0
- package/skills/openprd-benchmark-router/references/source-policy.md +35 -0
- package/skills/openprd-diagram-review/SKILL.md +91 -0
- package/skills/openprd-diagram-review/agents/openai.yaml +4 -0
- package/skills/openprd-diagram-review/examples/architecture-zh.md +8 -0
- package/skills/openprd-diagram-review/examples/product-flow-zh.md +7 -0
- package/skills/openprd-diagram-review/references/cocoon-patterns.md +17 -0
- package/skills/openprd-diagram-review/references/diagram-contracts.md +126 -0
- package/skills/openprd-diagram-review/references/review-checklist.md +10 -0
- package/skills/openprd-discovery-loop/SKILL.md +196 -0
- package/skills/openprd-discovery-loop/agents/openai.yaml +3 -0
- package/skills/openprd-harness/SKILL.md +179 -0
- package/skills/openprd-harness/agents/openai.yaml +4 -0
- package/skills/openprd-harness/examples/full-workflow-zh.md +9 -0
- package/skills/openprd-harness/references/command-map.md +71 -0
- package/skills/openprd-harness/references/examples.md +26 -0
- package/skills/openprd-harness/references/usage-guide.md +335 -0
- package/skills/openprd-harness/references/workflow-gates.md +51 -0
- package/skills/openprd-learning-review/SKILL.md +75 -0
- package/skills/openprd-learning-review/agents/openai.yaml +4 -0
- package/skills/openprd-learning-review/references/content-contract.md +125 -0
- package/skills/openprd-learning-review/references/ebook-reader.md +46 -0
- package/skills/openprd-learning-review/references/evidence-manifest.md +55 -0
- package/skills/openprd-learning-review/references/genre-library.md +43 -0
- package/skills/openprd-learning-review/references/prompt-engineering.md +71 -0
- package/skills/openprd-learning-review/references/quality-rubric.md +28 -0
- package/skills/openprd-learning-review/references/retrieval-worked-example.md +40 -0
- package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +67 -0
- package/skills/openprd-quality/SKILL.md +101 -0
- package/skills/openprd-requirement-intake/SKILL.md +76 -0
- package/skills/openprd-requirement-intake/agents/openai.yaml +4 -0
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +105 -0
- package/skills/openprd-requirement-intake/references/routing-rubric.md +64 -0
- package/skills/openprd-router/SKILL.md +40 -0
- package/skills/openprd-shared/SKILL.md +142 -0
- package/skills/openprd-shared/agents/openai.yaml +4 -0
- package/skills/openprd-shared/references/language-and-review.md +50 -0
- package/skills/openprd-shared/references/operating-rules.md +65 -0
- package/skills/openprd-shared/references/skill-architecture.md +70 -0
- package/skills/openprd-standards/SKILL.md +79 -0
- package/skills/openprd-standards/agents/openai.yaml +4 -0
- package/src/agent-integration.js +1717 -0
- package/src/benchmark.js +873 -0
- package/src/cli/args.js +460 -0
- package/src/cli/print.js +1423 -0
- package/src/codex-hook-runner-template.mjs +2422 -0
- package/src/dev-standards.js +372 -0
- package/src/diagram-core.js +1047 -0
- package/src/diagram-workspace.js +262 -0
- package/src/discovery.js +709 -0
- package/src/fleet.js +531 -0
- package/src/fs-utils.js +83 -0
- package/src/growth.js +545 -0
- package/src/html-artifacts.js +3803 -0
- package/src/knowledge.js +668 -0
- package/src/language-policy.js +142 -0
- package/src/learning-review.js +1655 -0
- package/src/loop.js +1290 -0
- package/src/openprd.js +1136 -0
- package/src/openspec/change-lifecycle.js +359 -0
- package/src/openspec/change-validate.js +248 -0
- package/src/openspec/constants.js +12 -0
- package/src/openspec/execute.js +300 -0
- package/src/openspec/generate.js +692 -0
- package/src/openspec/paths.js +111 -0
- package/src/openspec/tasks.js +352 -0
- package/src/prd-core.js +656 -0
- package/src/quality-html-artifact.js +1414 -0
- package/src/quality-learning.js +658 -0
- package/src/quality.js +1262 -0
- package/src/review-presentation.js +240 -0
- package/src/run-harness.js +1470 -0
- package/src/self-update.js +329 -0
- package/src/session-binding.js +140 -0
- package/src/source-inventory.js +224 -0
- package/src/standards.js +914 -0
- package/src/time.js +33 -0
- package/src/visual-compare.js +216 -0
- package/src/work-unit-migration.js +232 -0
- package/src/work-unit.js +88 -0
- package/src/workspace-core.js +1706 -0
- package/src/workspace-registry.js +162 -0
- package/src/workspace-workflow.js +1797 -0
package/src/loop.js
ADDED
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { defaultRegressionArtifactPath, renderRegressionArtifact, writeHtmlArtifact } from './html-artifacts.js';
|
|
6
|
+
import { OPENPRD_HARNESS_TURN_STATE, recordKnowledgeReviewSignal, reviewKnowledgeWorkspace } from './knowledge.js';
|
|
7
|
+
import { generateLearningReviewWorkspace } from './learning-review.js';
|
|
8
|
+
import { listOpenSpecTaskWorkspace, advanceOpenSpecTaskWorkspace, verifyOpenSpecTaskWorkspace } from './openspec/execute.js';
|
|
9
|
+
import { validateOpenSpecChangeWorkspace } from './openspec/change-validate.js';
|
|
10
|
+
import { verifyQualityWorkspace } from './quality.js';
|
|
11
|
+
import { timestamp } from './time.js';
|
|
12
|
+
|
|
13
|
+
const LOOP_FEATURE_LIST = path.join('.openprd', 'harness', 'feature-list.json');
|
|
14
|
+
const LOOP_STATE = path.join('.openprd', 'harness', 'loop-state.json');
|
|
15
|
+
const LOOP_PROGRESS = path.join('.openprd', 'harness', 'progress.md');
|
|
16
|
+
const LOOP_FAILED_APPROACHES = path.join('.openprd', 'harness', 'failed-approaches.md');
|
|
17
|
+
const LOOP_SESSIONS = path.join('.openprd', 'harness', 'agent-sessions.jsonl');
|
|
18
|
+
const LOOP_BOOTSTRAP = path.join('.openprd', 'harness', 'bootstrap.sh');
|
|
19
|
+
const LOOP_PROMPTS_DIR = path.join('.openprd', 'harness', 'loop-prompts');
|
|
20
|
+
const LOOP_TEST_REPORTS_DIR = path.join('.openprd', 'harness', 'test-reports');
|
|
21
|
+
const LOOP_AGENT_VALUES = ['codex', 'claude'];
|
|
22
|
+
|
|
23
|
+
function cjoin(...parts) {
|
|
24
|
+
return path.join(...parts);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function exists(filePath) {
|
|
28
|
+
return fs.access(filePath).then(() => true).catch(() => false);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readText(filePath) {
|
|
32
|
+
return fs.readFile(filePath, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeText(filePath, text) {
|
|
36
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
37
|
+
await fs.writeFile(filePath, text, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function appendText(filePath, text) {
|
|
41
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
42
|
+
await fs.appendFile(filePath, text, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readJson(filePath) {
|
|
46
|
+
return JSON.parse(await readText(filePath));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function writeJson(filePath, value) {
|
|
50
|
+
await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function appendJsonl(filePath, value) {
|
|
54
|
+
await appendText(filePath, `${JSON.stringify(value)}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function harnessPath(projectRoot, relativePath) {
|
|
58
|
+
return cjoin(projectRoot, relativePath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeAgent(agent = 'codex') {
|
|
62
|
+
if (!LOOP_AGENT_VALUES.includes(agent)) {
|
|
63
|
+
throw new Error(`Unsupported loop agent: ${agent}. Use codex or claude.`);
|
|
64
|
+
}
|
|
65
|
+
return agent;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function slugifyLoopToken(value, fallback = 'task') {
|
|
69
|
+
const slug = String(value ?? '')
|
|
70
|
+
.normalize('NFKC')
|
|
71
|
+
.trim()
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
74
|
+
.replace(/^-+|-+$/g, '')
|
|
75
|
+
.slice(0, 80);
|
|
76
|
+
return slug || fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildTaskHandle(changeId, task) {
|
|
80
|
+
return [
|
|
81
|
+
slugifyLoopToken(changeId ?? 'change', 'change'),
|
|
82
|
+
String(task.id ?? 'task').trim() || 'task',
|
|
83
|
+
slugifyLoopToken(task.title ?? task.id ?? 'task', 'task'),
|
|
84
|
+
].join(':');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeTaskReference(value) {
|
|
88
|
+
return String(value ?? '')
|
|
89
|
+
.normalize('NFKC')
|
|
90
|
+
.trim()
|
|
91
|
+
.toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function taskReferenceCandidates(task) {
|
|
95
|
+
return new Set([
|
|
96
|
+
task.id,
|
|
97
|
+
task.title,
|
|
98
|
+
task.taskHandle,
|
|
99
|
+
task.taskSlug,
|
|
100
|
+
task.changeId && task.id ? `${task.changeId}:${task.id}` : null,
|
|
101
|
+
task.id && task.taskSlug ? `${task.id}:${task.taskSlug}` : null,
|
|
102
|
+
].filter(Boolean).map((item) => normalizeTaskReference(item)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function bootstrapScript() {
|
|
106
|
+
return [
|
|
107
|
+
'#!/usr/bin/env bash',
|
|
108
|
+
'set -euo pipefail',
|
|
109
|
+
'ROOT="${1:-$(pwd)}"',
|
|
110
|
+
'cd "$ROOT"',
|
|
111
|
+
'echo "[openprd-loop] workspace: $PWD"',
|
|
112
|
+
'openprd doctor . --tools all',
|
|
113
|
+
'openprd run . --context',
|
|
114
|
+
'git status --short || true',
|
|
115
|
+
'',
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function ensureLoopFiles(projectRoot) {
|
|
120
|
+
await fs.mkdir(harnessPath(projectRoot, path.dirname(LOOP_FEATURE_LIST)), { recursive: true });
|
|
121
|
+
await fs.mkdir(harnessPath(projectRoot, LOOP_PROMPTS_DIR), { recursive: true });
|
|
122
|
+
await fs.mkdir(harnessPath(projectRoot, LOOP_TEST_REPORTS_DIR), { recursive: true });
|
|
123
|
+
|
|
124
|
+
const bootstrapPath = harnessPath(projectRoot, LOOP_BOOTSTRAP);
|
|
125
|
+
if (!(await exists(bootstrapPath))) {
|
|
126
|
+
await writeText(bootstrapPath, bootstrapScript());
|
|
127
|
+
await fs.chmod(bootstrapPath, 0o755).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
if (!(await exists(harnessPath(projectRoot, LOOP_PROGRESS)))) {
|
|
130
|
+
await writeText(harnessPath(projectRoot, LOOP_PROGRESS), '# OpenPrd Loop Progress\n\n');
|
|
131
|
+
}
|
|
132
|
+
if (!(await exists(harnessPath(projectRoot, LOOP_FAILED_APPROACHES)))) {
|
|
133
|
+
await writeText(
|
|
134
|
+
harnessPath(projectRoot, LOOP_FAILED_APPROACHES),
|
|
135
|
+
'# OpenPrd Failed Approaches\n\nRecord dead ends, mismatches, and why they were rejected so the next session does not repeat them.\n',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!(await exists(harnessPath(projectRoot, LOOP_SESSIONS)))) {
|
|
139
|
+
await writeText(harnessPath(projectRoot, LOOP_SESSIONS), '');
|
|
140
|
+
}
|
|
141
|
+
if (!(await exists(harnessPath(projectRoot, LOOP_STATE)))) {
|
|
142
|
+
await writeJson(harnessPath(projectRoot, LOOP_STATE), {
|
|
143
|
+
version: 1,
|
|
144
|
+
active: true,
|
|
145
|
+
currentTaskId: null,
|
|
146
|
+
currentTaskHandle: null,
|
|
147
|
+
currentTaskTitle: null,
|
|
148
|
+
completedTaskIds: [],
|
|
149
|
+
lastAgent: null,
|
|
150
|
+
lastSessionAt: null,
|
|
151
|
+
updatedAt: timestamp(),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function taskDeps(task) {
|
|
157
|
+
const deps = task.metadata?.deps ?? '';
|
|
158
|
+
return String(deps)
|
|
159
|
+
.split(',')
|
|
160
|
+
.map((item) => item.trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function combinedLoopEvidenceText(options = {}) {
|
|
165
|
+
return [options.notes, options.evidence]
|
|
166
|
+
.map((value) => String(value ?? '').trim())
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.join('\n')
|
|
169
|
+
.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validateLoopFinishEvidence(task, options = {}) {
|
|
173
|
+
const evidenceText = combinedLoopEvidenceText(options);
|
|
174
|
+
if (!task.oracle) {
|
|
175
|
+
return { ok: true, evidenceText };
|
|
176
|
+
}
|
|
177
|
+
if (evidenceText) {
|
|
178
|
+
return { ok: true, evidenceText };
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
evidenceText,
|
|
183
|
+
error: `任务 ${task.id} 定义了 oracle/reference 对照基准;loop finish 时必须通过 --notes 或 --evidence 记录本轮对照结果。`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderFailedApproachEntry({ task, stage, reason, verification, notes, evidence }) {
|
|
188
|
+
return [
|
|
189
|
+
`\n## ${timestamp()} ${task.id} ${task.title}`,
|
|
190
|
+
'',
|
|
191
|
+
`- 阶段: ${stage}`,
|
|
192
|
+
task.oracle ? `- 对照基准: ${task.oracle}` : null,
|
|
193
|
+
verification?.command ? `- 自测命令: ${verification.command}` : null,
|
|
194
|
+
`- 原因: ${reason}`,
|
|
195
|
+
notes ? `- 备注: ${notes}` : null,
|
|
196
|
+
evidence ? `- 补充证据: ${evidence}` : null,
|
|
197
|
+
verification?.stdout ? `- 输出摘要: ${trimOutput(verification.stdout).replace(/\s+/g, ' ')}` : null,
|
|
198
|
+
verification?.stderr ? `- 错误摘要: ${trimOutput(verification.stderr).replace(/\s+/g, ' ')}` : null,
|
|
199
|
+
'',
|
|
200
|
+
].filter(Boolean).join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function appendFailedApproach(projectRoot, payload) {
|
|
204
|
+
await appendText(harnessPath(projectRoot, LOOP_FAILED_APPROACHES), renderFailedApproachEntry(payload));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function featureTaskFromOpenSpecTask(task, changeId) {
|
|
208
|
+
const deps = taskDeps(task);
|
|
209
|
+
const taskSlug = slugifyLoopToken(task.title ?? task.id ?? 'task', 'task');
|
|
210
|
+
return {
|
|
211
|
+
id: task.id,
|
|
212
|
+
title: task.title,
|
|
213
|
+
taskSlug,
|
|
214
|
+
taskHandle: buildTaskHandle(changeId, task),
|
|
215
|
+
status: task.checked ? 'done' : 'pending',
|
|
216
|
+
changeId,
|
|
217
|
+
sourceTaskId: task.id,
|
|
218
|
+
sourcePath: task.relativePath,
|
|
219
|
+
sourceLine: task.lineNumber,
|
|
220
|
+
deps,
|
|
221
|
+
type: task.metadata?.type ?? task.metadata?.category ?? task.metadata?.kind ?? null,
|
|
222
|
+
done: task.metadata?.done ?? null,
|
|
223
|
+
verify: task.metadata?.verify ?? null,
|
|
224
|
+
oracle: task.metadata?.oracle ?? null,
|
|
225
|
+
commitMessage: `Complete ${task.id}: ${task.title}`,
|
|
226
|
+
sessionScope: [
|
|
227
|
+
'只处理这个任务,不要在同一会话继续下一个任务。',
|
|
228
|
+
'完成代码后必须先自测,失败就修复并重新自测。',
|
|
229
|
+
'代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 `openprd dev-check . <file...>`;若出现 attention 或 warning,说明局部职责、影响范围,以及是否已拆分或为什么窄修暂不拆。',
|
|
230
|
+
'涉及前端界面时,在 Codex 客户端优先使用 Computer Use;在 Codex CLI 或 Claude Code 中优先使用 Playwright、MCP 或等价浏览器自动化。',
|
|
231
|
+
'纯后端、脚本或库任务使用最贴近项目的脚本、单测、集成测试或命令行验证。',
|
|
232
|
+
'涉及后端、脚本、Agent、工具链、服务或数据处理变更时,把 CLI 与 API 视为同级接入面;检查命令入口、参数、输出契约、`help`/`doctor`/`dry-run`/`status` 与接口协议、返回结构、身份边界是否受影响,并同步更新 `docs/basic/backend-structure.md`;若某一面不适用也要明确写原因。',
|
|
233
|
+
'新增或修改文件时先做文档影响判定:缺少 docs/basic、文件说明书或文件夹 README 就补齐;已有文档若因本任务职责、流程、结构、依赖或产品行为变化而过期,就同步更新。',
|
|
234
|
+
],
|
|
235
|
+
updatedAt: timestamp(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mergeExistingTaskState(existing, nextTask) {
|
|
240
|
+
if (!existing) return nextTask;
|
|
241
|
+
const preservedStatus = ['running', 'verified', 'done', 'failed', 'blocked'].includes(existing.status)
|
|
242
|
+
? existing.status
|
|
243
|
+
: nextTask.status;
|
|
244
|
+
return {
|
|
245
|
+
...nextTask,
|
|
246
|
+
status: nextTask.status === 'done' ? 'done' : preservedStatus,
|
|
247
|
+
lastSessionId: existing.lastSessionId ?? null,
|
|
248
|
+
lastSessionAt: existing.lastSessionAt ?? null,
|
|
249
|
+
lastVerifiedAt: existing.lastVerifiedAt ?? null,
|
|
250
|
+
lastCommittedAt: existing.lastCommittedAt ?? null,
|
|
251
|
+
commitSha: existing.commitSha ?? null,
|
|
252
|
+
updatedAt: timestamp(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readFeatureList(projectRoot) {
|
|
257
|
+
const filePath = harnessPath(projectRoot, LOOP_FEATURE_LIST);
|
|
258
|
+
if (!(await exists(filePath))) return null;
|
|
259
|
+
return readJson(filePath);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildLoopSummary(featureList) {
|
|
263
|
+
const tasks = featureList?.tasks ?? [];
|
|
264
|
+
return {
|
|
265
|
+
total: tasks.length,
|
|
266
|
+
done: tasks.filter((task) => task.status === 'done').length,
|
|
267
|
+
pending: tasks.filter((task) => task.status === 'pending').length,
|
|
268
|
+
running: tasks.filter((task) => task.status === 'running').length,
|
|
269
|
+
verified: tasks.filter((task) => task.status === 'verified').length,
|
|
270
|
+
failed: tasks.filter((task) => task.status === 'failed').length,
|
|
271
|
+
blocked: tasks.filter((task) => task.status === 'blocked').length,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function dependencyState(task, tasks) {
|
|
276
|
+
const taskById = new Map(tasks.map((item) => [item.id, item]));
|
|
277
|
+
const missing = [];
|
|
278
|
+
const incomplete = [];
|
|
279
|
+
for (const depId of task.deps ?? []) {
|
|
280
|
+
const dep = taskById.get(depId);
|
|
281
|
+
if (!dep) {
|
|
282
|
+
missing.push(depId);
|
|
283
|
+
} else if (dep.status !== 'done') {
|
|
284
|
+
incomplete.push(depId);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
missing,
|
|
289
|
+
incomplete,
|
|
290
|
+
ready: missing.length === 0 && incomplete.length === 0,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resolveLoopTask(featureList, requestedRef) {
|
|
295
|
+
const tasks = featureList?.tasks ?? [];
|
|
296
|
+
const normalized = normalizeTaskReference(requestedRef);
|
|
297
|
+
const matches = tasks.filter((task) => taskReferenceCandidates(task).has(normalized));
|
|
298
|
+
if (matches.length === 1) {
|
|
299
|
+
return matches[0];
|
|
300
|
+
}
|
|
301
|
+
if (matches.length > 1) {
|
|
302
|
+
throw new Error(`Ambiguous OpenPrd loop task reference: ${requestedRef}`);
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`Unknown OpenPrd loop task: ${requestedRef}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function nextLoopTask(featureList, requestedId = null) {
|
|
308
|
+
const tasks = featureList?.tasks ?? [];
|
|
309
|
+
if (requestedId) {
|
|
310
|
+
const task = resolveLoopTask(featureList, requestedId);
|
|
311
|
+
return { task, dependencyState: dependencyState(task, tasks) };
|
|
312
|
+
}
|
|
313
|
+
for (const task of tasks) {
|
|
314
|
+
if (!['pending', 'failed'].includes(task.status)) continue;
|
|
315
|
+
const state = dependencyState(task, tasks);
|
|
316
|
+
if (state.ready) return { task, dependencyState: state };
|
|
317
|
+
}
|
|
318
|
+
return { task: null, dependencyState: null };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function writeFeatureList(projectRoot, featureList) {
|
|
322
|
+
await writeJson(harnessPath(projectRoot, LOOP_FEATURE_LIST), featureList);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function updateLoopState(projectRoot, patch) {
|
|
326
|
+
const statePath = harnessPath(projectRoot, LOOP_STATE);
|
|
327
|
+
const state = (await exists(statePath)) ? await readJson(statePath) : {};
|
|
328
|
+
const next = {
|
|
329
|
+
version: 1,
|
|
330
|
+
active: true,
|
|
331
|
+
...state,
|
|
332
|
+
...patch,
|
|
333
|
+
updatedAt: timestamp(),
|
|
334
|
+
};
|
|
335
|
+
await writeJson(statePath, next);
|
|
336
|
+
return next;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function renderProgressEntry(title, lines) {
|
|
340
|
+
return `\n## ${title}\n\n${lines.filter(Boolean).map((line) => `- ${line}`).join('\n')}\n`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function shellJoin(args) {
|
|
344
|
+
return args.map((arg) => {
|
|
345
|
+
const text = String(arg);
|
|
346
|
+
if (/^[a-zA-Z0-9_./:=@-]+$/.test(text)) return text;
|
|
347
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
348
|
+
}).join(' ');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function defaultAgentInvocation(agent, projectRoot, promptPath) {
|
|
352
|
+
if (agent === 'codex') {
|
|
353
|
+
return {
|
|
354
|
+
command: 'codex',
|
|
355
|
+
args: ['exec', '--full-auto', '-C', projectRoot, '-'],
|
|
356
|
+
stdinFile: promptPath,
|
|
357
|
+
display: `codex exec --full-auto -C ${shellJoin([projectRoot])} - < ${shellJoin([promptPath])}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
command: 'claude',
|
|
362
|
+
args: ['--print', '--permission-mode', 'auto', '--output-format', 'text'],
|
|
363
|
+
stdinFile: promptPath,
|
|
364
|
+
display: `claude --print --permission-mode auto --output-format text < ${shellJoin([promptPath])}`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderLoopPrompt({ agent, projectRoot, featureList, task, dependency, mode }) {
|
|
369
|
+
const screenshotPath = screenshotHintPath(projectRoot, task.id);
|
|
370
|
+
const frontendStrategy = [
|
|
371
|
+
'- 如果任务涉及页面、组件、样式、前端交互或浏览器行为,必须做界面级验证。',
|
|
372
|
+
'- Codex 客户端环境: 优先使用 Computer Use 以第三方视角打开页面、点击、输入、截图或读取可访问性树。',
|
|
373
|
+
'- Codex CLI / Claude Code 环境: 优先使用 Playwright、MCP 浏览器自动化或项目已有 e2e 工具。',
|
|
374
|
+
`- 如需截图证据,默认保存到 ${screenshotPath},并在 loop finish 时通过 --evidence 传入该路径。`,
|
|
375
|
+
'- 每次发现问题后先修复,再重新运行验证;验证通过后才能提交。',
|
|
376
|
+
];
|
|
377
|
+
return [
|
|
378
|
+
'# OpenPrd 长程单任务执行会话',
|
|
379
|
+
'',
|
|
380
|
+
`Agent: ${agent}`,
|
|
381
|
+
`模式: ${mode}`,
|
|
382
|
+
`项目: ${projectRoot}`,
|
|
383
|
+
`变更: ${task.changeId}`,
|
|
384
|
+
`任务: ${task.id} ${task.title}`,
|
|
385
|
+
`任务句柄: ${task.taskHandle}`,
|
|
386
|
+
'',
|
|
387
|
+
'## Harness 契约',
|
|
388
|
+
'',
|
|
389
|
+
'你正在运行一个隔离的 OpenPrd loop 单任务会话。本会话不假设拥有前一个会话的对话记忆。',
|
|
390
|
+
'连续性只来自项目文件、OpenPrd 状态文件、测试报告和 Git 历史。',
|
|
391
|
+
'',
|
|
392
|
+
'## 启动步骤',
|
|
393
|
+
'',
|
|
394
|
+
'1. 读取 `AGENTS.md`,遵守 OpenPrd managed block。',
|
|
395
|
+
'2. 如存在 `.openprd/harness/bootstrap.sh`,先运行 `.openprd/harness/bootstrap.sh .`。',
|
|
396
|
+
'3. 查看 `git status --short`,不要覆盖无关用户改动。',
|
|
397
|
+
'4. 读取 `.openprd/harness/feature-list.json`、`.openprd/harness/progress.md`、`.openprd/harness/failed-approaches.md` 和本任务来源文件。',
|
|
398
|
+
'',
|
|
399
|
+
'## 单任务边界',
|
|
400
|
+
'',
|
|
401
|
+
`只实现任务 ${task.id}: ${task.title}`,
|
|
402
|
+
`跨对话继续请引用: ${task.taskHandle}`,
|
|
403
|
+
`完成条件: ${task.done ?? '未指定'}`,
|
|
404
|
+
`自测命令: ${task.verify ?? '未指定'}`,
|
|
405
|
+
`对照基准: ${task.oracle ?? '未指定'}`,
|
|
406
|
+
`依赖是否就绪: ${dependency?.ready ? '是' : '否'}`,
|
|
407
|
+
dependency?.missing?.length ? `缺失依赖: ${dependency.missing.join(', ')}` : '',
|
|
408
|
+
dependency?.incomplete?.length ? `未完成依赖: ${dependency.incomplete.join(', ')}` : '',
|
|
409
|
+
`来源: ${task.sourcePath}:${task.sourceLine}`,
|
|
410
|
+
'',
|
|
411
|
+
'不要开始下一个任务。如果发现任务仍然过大,先拆分任务文件,并只完成最小可用切片。',
|
|
412
|
+
task.oracle ? '如果任务定义了对照基准,必须显式对照 reference/oracle,并把偏差、死路或替代方案记到 `.openprd/harness/failed-approaches.md`。' : '',
|
|
413
|
+
'代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 `openprd dev-check . <file...>`;若出现 attention 或 warning,说明局部职责、影响范围,以及是否已拆分或为什么窄修暂不拆。',
|
|
414
|
+
'',
|
|
415
|
+
'## 自测与界面验证要求',
|
|
416
|
+
'',
|
|
417
|
+
'1. 必须运行本任务的自测命令。',
|
|
418
|
+
'2. 必须运行 `openprd run . --verify`。',
|
|
419
|
+
...frontendStrategy,
|
|
420
|
+
'5. 阶段性测试报告会由 `openprd loop . --finish` 写入 `.openprd/harness/test-reports/`,并与本任务改动一起进入 commit。',
|
|
421
|
+
'',
|
|
422
|
+
'## 收尾步骤',
|
|
423
|
+
'',
|
|
424
|
+
'1. 确认自测、界面验证和 OpenPrd verify 都已经通过。',
|
|
425
|
+
'2. 留下简洁总结,说明改动文件和验证结果。',
|
|
426
|
+
'3. 如果这是手动执行 prompt,用以下命令结束任务并提交:',
|
|
427
|
+
task.oracle
|
|
428
|
+
? ` openprd loop . --finish --item ${task.id} --commit --notes "<oracle/result summary>" --message ${JSON.stringify(task.commitMessage)}`
|
|
429
|
+
: ` openprd loop . --finish --item ${task.id} --commit --message ${JSON.stringify(task.commitMessage)}`,
|
|
430
|
+
'',
|
|
431
|
+
'## 任务快照',
|
|
432
|
+
'',
|
|
433
|
+
JSON.stringify({
|
|
434
|
+
version: featureList.version,
|
|
435
|
+
changeId: featureList.changeId,
|
|
436
|
+
summary: buildLoopSummary(featureList),
|
|
437
|
+
task: {
|
|
438
|
+
id: task.id,
|
|
439
|
+
title: task.title,
|
|
440
|
+
taskHandle: task.taskHandle,
|
|
441
|
+
status: task.status,
|
|
442
|
+
type: task.type,
|
|
443
|
+
deps: task.deps,
|
|
444
|
+
done: task.done,
|
|
445
|
+
verify: task.verify,
|
|
446
|
+
oracle: task.oracle,
|
|
447
|
+
},
|
|
448
|
+
}, null, 2),
|
|
449
|
+
'',
|
|
450
|
+
].filter((line) => line !== '').join('\n');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function runCommand(command, args, options = {}) {
|
|
454
|
+
return new Promise((resolve) => {
|
|
455
|
+
const child = spawn(command, args, {
|
|
456
|
+
cwd: options.cwd,
|
|
457
|
+
shell: Boolean(options.shell),
|
|
458
|
+
env: options.env,
|
|
459
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
460
|
+
});
|
|
461
|
+
let stdout = '';
|
|
462
|
+
let stderr = '';
|
|
463
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
464
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
465
|
+
child.on('error', (error) => resolve({ ok: false, status: null, stdout, stderr, error: error.message }));
|
|
466
|
+
child.on('close', (status) => resolve({ ok: status === 0, status, stdout, stderr }));
|
|
467
|
+
if (options.stdin) {
|
|
468
|
+
child.stdin.write(options.stdin);
|
|
469
|
+
}
|
|
470
|
+
child.stdin.end();
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function gitCommit(projectRoot, message) {
|
|
475
|
+
const status = await runCommand('git', ['status', '--porcelain'], { cwd: projectRoot });
|
|
476
|
+
if (!status.ok) {
|
|
477
|
+
return { ok: false, skipped: false, message: 'git status 执行失败', status };
|
|
478
|
+
}
|
|
479
|
+
if (!status.stdout.trim()) {
|
|
480
|
+
return { ok: true, skipped: true, message: '没有需要提交的 Git 变更。' };
|
|
481
|
+
}
|
|
482
|
+
const add = await runCommand('git', ['add', '-A'], { cwd: projectRoot });
|
|
483
|
+
if (!add.ok) {
|
|
484
|
+
return { ok: false, skipped: false, message: 'git add 执行失败', add };
|
|
485
|
+
}
|
|
486
|
+
const commit = await runCommand('git', ['commit', '-m', message], { cwd: projectRoot });
|
|
487
|
+
if (!commit.ok) {
|
|
488
|
+
return { ok: false, skipped: false, message: 'git commit 执行失败', commit };
|
|
489
|
+
}
|
|
490
|
+
const rev = await runCommand('git', ['rev-parse', '--short', 'HEAD'], { cwd: projectRoot });
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
skipped: false,
|
|
494
|
+
message: '已提交',
|
|
495
|
+
sha: rev.stdout.trim() || null,
|
|
496
|
+
commit,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function trimOutput(value) {
|
|
501
|
+
const text = String(value ?? '').trim();
|
|
502
|
+
if (!text) return '无';
|
|
503
|
+
return text.length > 4000 ? `${text.slice(-4000)}\n...` : text;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function reportFileName(taskId) {
|
|
507
|
+
return `${taskId.replace(/[^a-zA-Z0-9._-]/g, '_')}.md`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function inferUiVerificationHint(task, agent = 'codex') {
|
|
511
|
+
const text = `${task.title} ${task.done ?? ''} ${task.verify ?? ''}`.toLowerCase();
|
|
512
|
+
const looksFrontend = /前端|界面|页面|组件|样式|布局|浏览器|ui|css|html|react|vue|svelte|playwright|e2e/.test(text);
|
|
513
|
+
if (!looksFrontend) {
|
|
514
|
+
return '未识别为前端界面任务;请以任务自测命令、单测、集成测试或脚本验证为主。';
|
|
515
|
+
}
|
|
516
|
+
if (agent === 'codex') {
|
|
517
|
+
return '识别为前端界面任务;Codex 客户端优先使用 Computer Use,Codex CLI 优先使用 Playwright/MCP 浏览器自动化。';
|
|
518
|
+
}
|
|
519
|
+
return '识别为前端界面任务;Claude Code 优先使用 Playwright、MCP 浏览器自动化或项目已有 e2e 工具。';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function screenshotHintPath(projectRoot, taskId) {
|
|
523
|
+
return harnessPath(projectRoot, cjoin(LOOP_TEST_REPORTS_DIR, 'evidence', `${taskId.replace(/[^a-zA-Z0-9._-]/g, '_')}.png`));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function parseEvidenceArtifacts(projectRoot, evidenceText) {
|
|
527
|
+
const raw = String(evidenceText ?? '').trim();
|
|
528
|
+
if (!raw) {
|
|
529
|
+
return { screenshots: [], textualEvidence: [] };
|
|
530
|
+
}
|
|
531
|
+
const entries = raw.split(/\n+/).map((item) => item.trim()).filter(Boolean);
|
|
532
|
+
const screenshots = [];
|
|
533
|
+
const textualEvidence = [];
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
const normalized = entry.replace(/^screenshot:\s*/i, '').trim();
|
|
536
|
+
if (/\.(png|jpe?g|webp|gif)$/i.test(normalized)) {
|
|
537
|
+
const absolute = path.isAbsolute(normalized) ? normalized : path.resolve(projectRoot, normalized);
|
|
538
|
+
screenshots.push({
|
|
539
|
+
path: absolute,
|
|
540
|
+
url: pathToFileURL(absolute).href,
|
|
541
|
+
});
|
|
542
|
+
} else {
|
|
543
|
+
textualEvidence.push(entry);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return { screenshots, textualEvidence };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function writeTestReport(projectRoot, { task, agent, advanced, change }) {
|
|
550
|
+
const relativePath = cjoin(LOOP_TEST_REPORTS_DIR, reportFileName(task.id));
|
|
551
|
+
const htmlRelativePath = cjoin(LOOP_TEST_REPORTS_DIR, `${task.id.replace(/[^a-zA-Z0-9._-]/g, '_')}.html`);
|
|
552
|
+
const evidenceText = advanced.evidence ?? inferUiVerificationHint(task, agent);
|
|
553
|
+
const notesText = advanced.notes ?? '无';
|
|
554
|
+
const evidenceArtifacts = parseEvidenceArtifacts(projectRoot, evidenceText);
|
|
555
|
+
const report = {
|
|
556
|
+
version: 1,
|
|
557
|
+
generatedAt: timestamp(),
|
|
558
|
+
kind: inferUiVerificationHint(task, agent).includes('前端界面任务') ? 'ui-regression' : 'command-regression',
|
|
559
|
+
verifyCommand: advanced.verification?.command ?? task.verify ?? '未指定',
|
|
560
|
+
oracle: task.oracle ?? null,
|
|
561
|
+
summary: {
|
|
562
|
+
total: 1,
|
|
563
|
+
passed: advanced.verification?.ok ? 1 : 0,
|
|
564
|
+
failed: advanced.verification?.ok ? 0 : 1,
|
|
565
|
+
},
|
|
566
|
+
cases: [
|
|
567
|
+
{
|
|
568
|
+
id: `${task.id}.verify`,
|
|
569
|
+
title: task.title,
|
|
570
|
+
expected: task.done ?? '满足任务完成条件',
|
|
571
|
+
actual: advanced.verification?.ok ? '验证命令执行通过' : '验证命令失败或未通过',
|
|
572
|
+
passed: Boolean(advanced.verification?.ok),
|
|
573
|
+
oracle: task.oracle ?? null,
|
|
574
|
+
evidence: evidenceText,
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
screenshots: evidenceArtifacts.screenshots,
|
|
578
|
+
textualEvidence: evidenceArtifacts.textualEvidence,
|
|
579
|
+
notes: notesText,
|
|
580
|
+
};
|
|
581
|
+
const lines = [
|
|
582
|
+
`# 阶段性测试报告: ${task.id} ${task.title}`,
|
|
583
|
+
'',
|
|
584
|
+
`- 测试时间: ${timestamp()}`,
|
|
585
|
+
`- 变更: ${task.changeId}`,
|
|
586
|
+
`- 完成条件: ${task.done ?? '未指定'}`,
|
|
587
|
+
`- 自测命令: ${advanced.verification?.command ?? task.verify ?? '未指定'}`,
|
|
588
|
+
`- 对照基准: ${task.oracle ?? '未指定'}`,
|
|
589
|
+
`- 自测结果: ${advanced.verification?.ok ? '通过' : '失败或未运行'}`,
|
|
590
|
+
`- Change 校验: ${change.ok ? '通过' : '失败'}`,
|
|
591
|
+
`- EVO 冒烟证据: ${advanced.verification?.ok ? 'smoke pass via task verify command' : 'smoke failed or missing'}`,
|
|
592
|
+
`- EVO 功能覆盖证据: feature coverage checked against OpenPrd task completion`,
|
|
593
|
+
`- 界面验证策略: ${inferUiVerificationHint(task, agent)}`,
|
|
594
|
+
`- 补充证据: ${evidenceText}`,
|
|
595
|
+
`- 备注: ${notesText}`,
|
|
596
|
+
'',
|
|
597
|
+
...(evidenceArtifacts.screenshots.length > 0 ? [
|
|
598
|
+
'## 截图证据',
|
|
599
|
+
'',
|
|
600
|
+
...evidenceArtifacts.screenshots.flatMap((item) => [
|
|
601
|
+
`- ${item.path}`,
|
|
602
|
+
``,
|
|
603
|
+
]),
|
|
604
|
+
'',
|
|
605
|
+
] : []),
|
|
606
|
+
'## 自测输出',
|
|
607
|
+
'',
|
|
608
|
+
'```text',
|
|
609
|
+
trimOutput(advanced.verification?.stdout),
|
|
610
|
+
'```',
|
|
611
|
+
'',
|
|
612
|
+
'## 错误输出',
|
|
613
|
+
'',
|
|
614
|
+
'```text',
|
|
615
|
+
trimOutput(advanced.verification?.stderr),
|
|
616
|
+
'```',
|
|
617
|
+
'',
|
|
618
|
+
'## OpenPrd 校验摘要',
|
|
619
|
+
'',
|
|
620
|
+
...(change.checks ?? []).map((check) => `- ${check}`),
|
|
621
|
+
...(change.warnings?.length ? ['', '## 警告', '', ...change.warnings.map((warning) => `- ${warning}`)] : []),
|
|
622
|
+
...(change.errors?.length ? ['', '## 错误', '', ...change.errors.map((error) => `- ${error}`)] : []),
|
|
623
|
+
'',
|
|
624
|
+
];
|
|
625
|
+
await writeText(harnessPath(projectRoot, relativePath), `${lines.join('\n')}\n`);
|
|
626
|
+
const htmlPath = defaultRegressionArtifactPath(projectRoot, task.id);
|
|
627
|
+
await writeHtmlArtifact(htmlPath, renderRegressionArtifact({ task, report }));
|
|
628
|
+
return {
|
|
629
|
+
markdownPath: relativePath,
|
|
630
|
+
htmlPath: htmlRelativePath,
|
|
631
|
+
report,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function initLoopWorkspace(projectRoot, options = {}) {
|
|
636
|
+
await ensureLoopFiles(projectRoot);
|
|
637
|
+
const featureList = (await readFeatureList(projectRoot)) ?? {
|
|
638
|
+
version: 1,
|
|
639
|
+
generatedAt: timestamp(),
|
|
640
|
+
updatedAt: timestamp(),
|
|
641
|
+
projectRoot,
|
|
642
|
+
changeId: options.change ?? null,
|
|
643
|
+
policy: {
|
|
644
|
+
oneTaskPerSession: true,
|
|
645
|
+
requireVerify: true,
|
|
646
|
+
requireCommit: true,
|
|
647
|
+
continuity: 'files-and-git-history',
|
|
648
|
+
},
|
|
649
|
+
source: 'openprd loop init',
|
|
650
|
+
tasks: [],
|
|
651
|
+
};
|
|
652
|
+
await writeFeatureList(projectRoot, featureList);
|
|
653
|
+
await appendText(harnessPath(projectRoot, LOOP_PROGRESS), renderProgressEntry(timestamp(), [
|
|
654
|
+
'Loop harness 已初始化。',
|
|
655
|
+
`默认 Agent: ${normalizeAgent(options.agent ?? 'codex')}。`,
|
|
656
|
+
]));
|
|
657
|
+
return {
|
|
658
|
+
ok: true,
|
|
659
|
+
action: 'loop-init',
|
|
660
|
+
projectRoot,
|
|
661
|
+
files: {
|
|
662
|
+
featureList: LOOP_FEATURE_LIST,
|
|
663
|
+
loopState: LOOP_STATE,
|
|
664
|
+
progress: LOOP_PROGRESS,
|
|
665
|
+
failedApproaches: LOOP_FAILED_APPROACHES,
|
|
666
|
+
sessions: LOOP_SESSIONS,
|
|
667
|
+
bootstrap: LOOP_BOOTSTRAP,
|
|
668
|
+
testReports: LOOP_TEST_REPORTS_DIR,
|
|
669
|
+
},
|
|
670
|
+
featureList,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export async function planLoopWorkspace(projectRoot, options = {}) {
|
|
675
|
+
await ensureLoopFiles(projectRoot);
|
|
676
|
+
const taskState = await listOpenSpecTaskWorkspace(projectRoot, { change: options.change });
|
|
677
|
+
const existing = await readFeatureList(projectRoot);
|
|
678
|
+
const existingById = new Map((existing?.tasks ?? []).map((task) => [task.id, task]));
|
|
679
|
+
const tasks = taskState.tasks.map((task) => mergeExistingTaskState(
|
|
680
|
+
existingById.get(task.id),
|
|
681
|
+
featureTaskFromOpenSpecTask(task, taskState.changeId),
|
|
682
|
+
));
|
|
683
|
+
const featureList = {
|
|
684
|
+
version: 1,
|
|
685
|
+
generatedAt: existing?.generatedAt ?? timestamp(),
|
|
686
|
+
updatedAt: timestamp(),
|
|
687
|
+
projectRoot,
|
|
688
|
+
changeId: taskState.changeId,
|
|
689
|
+
changeDir: path.relative(projectRoot, taskState.changeDir),
|
|
690
|
+
source: 'openprd loop plan',
|
|
691
|
+
policy: {
|
|
692
|
+
oneTaskPerSession: true,
|
|
693
|
+
requireVerify: true,
|
|
694
|
+
requireCommit: true,
|
|
695
|
+
continuity: 'files-and-git-history',
|
|
696
|
+
agentSessionRule: 'start a new Codex or Claude session for exactly one task',
|
|
697
|
+
testReportRule: 'write one staged test report before each task commit',
|
|
698
|
+
},
|
|
699
|
+
tasks,
|
|
700
|
+
};
|
|
701
|
+
await writeFeatureList(projectRoot, featureList);
|
|
702
|
+
await appendText(harnessPath(projectRoot, LOOP_PROGRESS), renderProgressEntry(timestamp(), [
|
|
703
|
+
`已从 change ${taskState.changeId} 规划 ${tasks.length} 个 loop 任务。`,
|
|
704
|
+
'每个任务都是独立 Agent 会话边界。',
|
|
705
|
+
]));
|
|
706
|
+
return {
|
|
707
|
+
ok: true,
|
|
708
|
+
action: 'loop-plan',
|
|
709
|
+
projectRoot,
|
|
710
|
+
changeId: taskState.changeId,
|
|
711
|
+
featureList,
|
|
712
|
+
summary: buildLoopSummary(featureList),
|
|
713
|
+
next: nextLoopTask(featureList).task,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export async function statusLoopWorkspace(projectRoot) {
|
|
718
|
+
await ensureLoopFiles(projectRoot);
|
|
719
|
+
const featureList = await readFeatureList(projectRoot);
|
|
720
|
+
if (!featureList) {
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
action: 'loop-status',
|
|
724
|
+
projectRoot,
|
|
725
|
+
summary: buildLoopSummary(null),
|
|
726
|
+
next: null,
|
|
727
|
+
errors: ['Loop feature list is missing. Run openprd loop . --plan --change <id>.'],
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const { task, dependencyState: state } = nextLoopTask(featureList);
|
|
731
|
+
return {
|
|
732
|
+
ok: true,
|
|
733
|
+
action: 'loop-status',
|
|
734
|
+
projectRoot,
|
|
735
|
+
changeId: featureList.changeId,
|
|
736
|
+
summary: buildLoopSummary(featureList),
|
|
737
|
+
next: task,
|
|
738
|
+
dependencyState: state,
|
|
739
|
+
files: {
|
|
740
|
+
featureList: LOOP_FEATURE_LIST,
|
|
741
|
+
progress: LOOP_PROGRESS,
|
|
742
|
+
failedApproaches: LOOP_FAILED_APPROACHES,
|
|
743
|
+
sessions: LOOP_SESSIONS,
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function nextLoopWorkspace(projectRoot, options = {}) {
|
|
749
|
+
const status = await statusLoopWorkspace(projectRoot);
|
|
750
|
+
if (!status.ok) return status;
|
|
751
|
+
if (options.item) {
|
|
752
|
+
const featureList = await readFeatureList(projectRoot);
|
|
753
|
+
const selected = nextLoopTask(featureList, options.item);
|
|
754
|
+
return {
|
|
755
|
+
...status,
|
|
756
|
+
action: 'loop-next',
|
|
757
|
+
next: selected.task,
|
|
758
|
+
dependencyState: selected.dependencyState,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return { ...status, action: 'loop-next' };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export async function promptLoopWorkspace(projectRoot, options = {}) {
|
|
765
|
+
await ensureLoopFiles(projectRoot);
|
|
766
|
+
const agent = normalizeAgent(options.agent ?? 'codex');
|
|
767
|
+
const featureList = await readFeatureList(projectRoot);
|
|
768
|
+
if (!featureList) {
|
|
769
|
+
throw new Error('Loop feature list is missing. Run openprd loop . --plan --change <id>.');
|
|
770
|
+
}
|
|
771
|
+
const { task, dependencyState: state } = nextLoopTask(featureList, options.item);
|
|
772
|
+
if (!task) {
|
|
773
|
+
return {
|
|
774
|
+
ok: false,
|
|
775
|
+
action: 'loop-prompt',
|
|
776
|
+
projectRoot,
|
|
777
|
+
agent,
|
|
778
|
+
errors: ['当前没有可执行的 loop 任务。'],
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
if (!state.ready) {
|
|
782
|
+
return {
|
|
783
|
+
ok: false,
|
|
784
|
+
action: 'loop-prompt',
|
|
785
|
+
projectRoot,
|
|
786
|
+
agent,
|
|
787
|
+
task,
|
|
788
|
+
dependencyState: state,
|
|
789
|
+
errors: [`任务 ${task.id} 尚未就绪。`],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const prompt = renderLoopPrompt({
|
|
793
|
+
agent,
|
|
794
|
+
projectRoot,
|
|
795
|
+
featureList,
|
|
796
|
+
task,
|
|
797
|
+
dependency: state,
|
|
798
|
+
mode: options.mode ?? 'manual',
|
|
799
|
+
});
|
|
800
|
+
const promptFileName = `${task.id.replace(/[^a-zA-Z0-9._-]/g, '_')}-${agent}-${Date.now()}.md`;
|
|
801
|
+
const promptPath = harnessPath(projectRoot, cjoin(LOOP_PROMPTS_DIR, promptFileName));
|
|
802
|
+
await writeText(promptPath, prompt);
|
|
803
|
+
const invocation = defaultAgentInvocation(agent, projectRoot, path.relative(projectRoot, promptPath));
|
|
804
|
+
return {
|
|
805
|
+
ok: true,
|
|
806
|
+
action: 'loop-prompt',
|
|
807
|
+
projectRoot,
|
|
808
|
+
agent,
|
|
809
|
+
task,
|
|
810
|
+
dependencyState: state,
|
|
811
|
+
prompt,
|
|
812
|
+
promptPath: path.relative(projectRoot, promptPath),
|
|
813
|
+
invocation,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export async function verifyLoopWorkspace(projectRoot, options = {}) {
|
|
818
|
+
await ensureLoopFiles(projectRoot);
|
|
819
|
+
const featureList = await readFeatureList(projectRoot);
|
|
820
|
+
if (!featureList) {
|
|
821
|
+
throw new Error('Loop feature list is missing. Run openprd loop . --plan --change <id>.');
|
|
822
|
+
}
|
|
823
|
+
const { task, dependencyState: state } = nextLoopTask(featureList, options.item);
|
|
824
|
+
if (!task) {
|
|
825
|
+
const summary = buildLoopSummary(featureList);
|
|
826
|
+
if (summary.total > 0 && summary.done === summary.total) {
|
|
827
|
+
return {
|
|
828
|
+
ok: true,
|
|
829
|
+
action: 'loop-verify',
|
|
830
|
+
projectRoot,
|
|
831
|
+
summary,
|
|
832
|
+
errors: [],
|
|
833
|
+
checks: ['所有 OpenPrd loop 任务均已完成。'],
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return { ok: false, action: 'loop-verify', projectRoot, summary, errors: ['当前没有可执行的 loop 任务。'] };
|
|
837
|
+
}
|
|
838
|
+
if (!state.ready) {
|
|
839
|
+
return { ok: false, action: 'loop-verify', projectRoot, task, dependencyState: state, errors: [`任务 ${task.id} 尚未就绪。`] };
|
|
840
|
+
}
|
|
841
|
+
const verify = await verifyOpenSpecTaskWorkspace(projectRoot, { change: task.changeId, item: task.sourceTaskId });
|
|
842
|
+
return {
|
|
843
|
+
ok: verify.ok,
|
|
844
|
+
action: 'loop-verify',
|
|
845
|
+
projectRoot,
|
|
846
|
+
task,
|
|
847
|
+
dependencyState: state,
|
|
848
|
+
verify,
|
|
849
|
+
errors: verify.ok ? [] : [verify.verification?.stderr || verify.verification?.stdout || `任务 ${task.id} 自测失败。`],
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function updateTask(featureList, taskId, patch) {
|
|
854
|
+
return {
|
|
855
|
+
...featureList,
|
|
856
|
+
updatedAt: timestamp(),
|
|
857
|
+
tasks: featureList.tasks.map((task) => (
|
|
858
|
+
task.id === taskId ? { ...task, ...patch, updatedAt: timestamp() } : task
|
|
859
|
+
)),
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function finishLoopWorkspace(projectRoot, options = {}) {
|
|
864
|
+
await ensureLoopFiles(projectRoot);
|
|
865
|
+
const featureList = await readFeatureList(projectRoot);
|
|
866
|
+
if (!featureList) {
|
|
867
|
+
throw new Error('Loop feature list is missing. Run openprd loop . --plan --change <id>.');
|
|
868
|
+
}
|
|
869
|
+
const { task, dependencyState: state } = nextLoopTask(featureList, options.item);
|
|
870
|
+
if (!task) {
|
|
871
|
+
return { ok: false, action: 'loop-finish', projectRoot, errors: ['当前没有可执行的 loop 任务。'] };
|
|
872
|
+
}
|
|
873
|
+
if (!state.ready) {
|
|
874
|
+
return { ok: false, action: 'loop-finish', projectRoot, task, dependencyState: state, errors: [`任务 ${task.id} 尚未就绪。`] };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const beforeChange = await validateOpenSpecChangeWorkspace(projectRoot, { change: task.changeId });
|
|
878
|
+
if (!beforeChange.ok) {
|
|
879
|
+
return {
|
|
880
|
+
ok: false,
|
|
881
|
+
action: 'loop-finish',
|
|
882
|
+
projectRoot,
|
|
883
|
+
task,
|
|
884
|
+
change: beforeChange,
|
|
885
|
+
errors: beforeChange.errors,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const verification = await verifyOpenSpecTaskWorkspace(projectRoot, {
|
|
890
|
+
change: task.changeId,
|
|
891
|
+
item: task.sourceTaskId,
|
|
892
|
+
evidence: options.evidence,
|
|
893
|
+
notes: options.notes,
|
|
894
|
+
});
|
|
895
|
+
if (!verification.ok) {
|
|
896
|
+
const failureReason = verification.verification?.stderr || verification.verification?.stdout || '自测失败';
|
|
897
|
+
const failedList = updateTask(featureList, task.id, { status: 'failed', lastError: failureReason });
|
|
898
|
+
await writeFeatureList(projectRoot, failedList);
|
|
899
|
+
await appendFailedApproach(projectRoot, {
|
|
900
|
+
task,
|
|
901
|
+
stage: 'task-verify',
|
|
902
|
+
reason: failureReason,
|
|
903
|
+
verification: verification.verification,
|
|
904
|
+
notes: options.notes ?? null,
|
|
905
|
+
evidence: options.evidence ?? null,
|
|
906
|
+
});
|
|
907
|
+
return {
|
|
908
|
+
ok: false,
|
|
909
|
+
action: 'loop-finish',
|
|
910
|
+
projectRoot,
|
|
911
|
+
task,
|
|
912
|
+
verification,
|
|
913
|
+
errors: [failureReason || `任务 ${task.id} 自测失败。`],
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const finishEvidence = validateLoopFinishEvidence(task, options);
|
|
918
|
+
if (!finishEvidence.ok) {
|
|
919
|
+
const failedList = updateTask(featureList, task.id, { status: 'failed', lastError: finishEvidence.error });
|
|
920
|
+
await writeFeatureList(projectRoot, failedList);
|
|
921
|
+
await appendFailedApproach(projectRoot, {
|
|
922
|
+
task,
|
|
923
|
+
stage: 'finish-evidence',
|
|
924
|
+
reason: finishEvidence.error,
|
|
925
|
+
verification: verification.verification,
|
|
926
|
+
notes: options.notes ?? null,
|
|
927
|
+
evidence: options.evidence ?? null,
|
|
928
|
+
});
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
action: 'loop-finish',
|
|
932
|
+
projectRoot,
|
|
933
|
+
task,
|
|
934
|
+
verification,
|
|
935
|
+
errors: [finishEvidence.error],
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const advanced = await advanceOpenSpecTaskWorkspace(projectRoot, {
|
|
940
|
+
change: task.changeId,
|
|
941
|
+
item: task.sourceTaskId,
|
|
942
|
+
verify: false,
|
|
943
|
+
evidence: options.evidence,
|
|
944
|
+
notes: options.notes,
|
|
945
|
+
});
|
|
946
|
+
if (!advanced.ok) {
|
|
947
|
+
const failureReason = advanced.errors?.[0] ?? `任务 ${task.id} 标记完成失败。`;
|
|
948
|
+
const failedList = updateTask(featureList, task.id, { status: 'failed', lastError: failureReason });
|
|
949
|
+
await writeFeatureList(projectRoot, failedList);
|
|
950
|
+
await appendFailedApproach(projectRoot, {
|
|
951
|
+
task,
|
|
952
|
+
stage: 'task-advance',
|
|
953
|
+
reason: failureReason,
|
|
954
|
+
verification: verification.verification,
|
|
955
|
+
notes: options.notes ?? null,
|
|
956
|
+
evidence: options.evidence ?? null,
|
|
957
|
+
});
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
action: 'loop-finish',
|
|
961
|
+
projectRoot,
|
|
962
|
+
task,
|
|
963
|
+
verification,
|
|
964
|
+
advanced,
|
|
965
|
+
errors: [failureReason],
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
const change = beforeChange;
|
|
969
|
+
const finishResult = {
|
|
970
|
+
...advanced,
|
|
971
|
+
verification: verification.verification,
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
let commit = null;
|
|
975
|
+
const testReport = await writeTestReport(projectRoot, {
|
|
976
|
+
task,
|
|
977
|
+
agent: options.agent ?? 'codex',
|
|
978
|
+
advanced: {
|
|
979
|
+
...finishResult,
|
|
980
|
+
evidence: options.evidence ?? null,
|
|
981
|
+
notes: options.notes ?? null,
|
|
982
|
+
},
|
|
983
|
+
change,
|
|
984
|
+
});
|
|
985
|
+
if (options.commit) {
|
|
986
|
+
commit = await gitCommit(projectRoot, options.message ?? task.commitMessage);
|
|
987
|
+
if (!commit.ok) {
|
|
988
|
+
return {
|
|
989
|
+
ok: false,
|
|
990
|
+
action: 'loop-finish',
|
|
991
|
+
projectRoot,
|
|
992
|
+
task,
|
|
993
|
+
advanced: finishResult,
|
|
994
|
+
change,
|
|
995
|
+
commit,
|
|
996
|
+
errors: [commit.message],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const updatedList = updateTask(featureList, task.id, {
|
|
1002
|
+
status: 'done',
|
|
1003
|
+
lastVerifiedAt: timestamp(),
|
|
1004
|
+
lastCommittedAt: commit && !commit.skipped ? timestamp() : null,
|
|
1005
|
+
commitSha: commit?.sha ?? null,
|
|
1006
|
+
lastTestReport: testReport.markdownPath,
|
|
1007
|
+
});
|
|
1008
|
+
const nextAfterFinish = nextLoopTask(updatedList).task;
|
|
1009
|
+
let quality = null;
|
|
1010
|
+
if (!nextAfterFinish) {
|
|
1011
|
+
quality = await verifyQualityWorkspace(projectRoot, { strict: true }).catch((error) => ({
|
|
1012
|
+
ok: false,
|
|
1013
|
+
action: 'quality-verify',
|
|
1014
|
+
errors: [error instanceof Error ? error.message : String(error)],
|
|
1015
|
+
}));
|
|
1016
|
+
const productionReady = quality.report?.readiness?.productionReady ?? null;
|
|
1017
|
+
if (!quality.ok || productionReady === false) {
|
|
1018
|
+
const attentionGates = quality.report?.readiness?.attentionGates ?? [];
|
|
1019
|
+
const qualityError = [
|
|
1020
|
+
'Final EVO quality gate is not production-ready.',
|
|
1021
|
+
attentionGates.length > 0 ? `Attention gates: ${attentionGates.join(', ')}` : null,
|
|
1022
|
+
quality.htmlPath ? `HTML report: ${path.relative(projectRoot, quality.htmlPath)}` : null,
|
|
1023
|
+
].filter(Boolean).join(' ');
|
|
1024
|
+
const failedList = updateTask(featureList, task.id, {
|
|
1025
|
+
status: 'failed',
|
|
1026
|
+
lastError: qualityError,
|
|
1027
|
+
lastTestReport: testReport.markdownPath,
|
|
1028
|
+
});
|
|
1029
|
+
await writeFeatureList(projectRoot, failedList);
|
|
1030
|
+
await appendFailedApproach(projectRoot, {
|
|
1031
|
+
task,
|
|
1032
|
+
stage: 'final-quality',
|
|
1033
|
+
reason: qualityError,
|
|
1034
|
+
verification: verification.verification,
|
|
1035
|
+
notes: options.notes ?? null,
|
|
1036
|
+
evidence: options.evidence ?? null,
|
|
1037
|
+
});
|
|
1038
|
+
return {
|
|
1039
|
+
ok: false,
|
|
1040
|
+
action: 'loop-finish',
|
|
1041
|
+
projectRoot,
|
|
1042
|
+
task,
|
|
1043
|
+
advanced: finishResult,
|
|
1044
|
+
change,
|
|
1045
|
+
commit,
|
|
1046
|
+
testReport: testReport.markdownPath,
|
|
1047
|
+
regressionHtml: testReport.htmlPath,
|
|
1048
|
+
quality,
|
|
1049
|
+
errors: [qualityError, ...(quality.errors ?? [])],
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
await writeFeatureList(projectRoot, updatedList);
|
|
1054
|
+
let learningReview = null;
|
|
1055
|
+
try {
|
|
1056
|
+
learningReview = await generateLearningReviewWorkspace(projectRoot, {
|
|
1057
|
+
trigger: 'loop-finish',
|
|
1058
|
+
topic: `${task.id} ${task.title}`,
|
|
1059
|
+
sourceScope: 'loop',
|
|
1060
|
+
respectConfig: true,
|
|
1061
|
+
taskId: task.id,
|
|
1062
|
+
changeId: task.changeId,
|
|
1063
|
+
verifyCommand: finishResult.verification?.command ?? task.verify ?? null,
|
|
1064
|
+
testReport: testReport.markdownPath,
|
|
1065
|
+
commitSha: commit?.sha ?? null,
|
|
1066
|
+
});
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
learningReview = {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
action: 'learning-review-generate',
|
|
1071
|
+
skipped: false,
|
|
1072
|
+
opened: false,
|
|
1073
|
+
errors: [error instanceof Error ? error.message : String(error)],
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
const learningProgress = learningReview?.skipped
|
|
1077
|
+
? [`复盘学习: 已跳过 (${learningReview.reason})。`]
|
|
1078
|
+
: learningReview?.ok
|
|
1079
|
+
? [
|
|
1080
|
+
`复盘学习包: ${learningReview.packageId}。`,
|
|
1081
|
+
`复盘写作状态: ${learningReview.packageMeta?.authoringStatus ?? 'unknown'}。`,
|
|
1082
|
+
`学习阅读器: ${path.relative(projectRoot, learningReview.packagePaths.readerHtml)}。`,
|
|
1083
|
+
...(learningReview.packagePaths?.agentPrompt ? [`Agent 写作提示: ${path.relative(projectRoot, learningReview.packagePaths.agentPrompt)}。`] : []),
|
|
1084
|
+
]
|
|
1085
|
+
: [`复盘学习: 生成失败 (${learningReview?.errors?.[0] ?? 'unknown'})。`];
|
|
1086
|
+
const knowledgeSignal = {
|
|
1087
|
+
kind: 'loop-finish',
|
|
1088
|
+
ok: true,
|
|
1089
|
+
productionReady: quality?.report?.readiness?.productionReady ?? null,
|
|
1090
|
+
attentionGates: quality?.report?.readiness?.attentionGates ?? [],
|
|
1091
|
+
summary: `loop finish ${task.id}: ${task.title}`,
|
|
1092
|
+
};
|
|
1093
|
+
await recordKnowledgeReviewSignal(projectRoot, knowledgeSignal).catch(() => null);
|
|
1094
|
+
const knowledgeReviewSource = (await exists(cjoin(projectRoot, OPENPRD_HARNESS_TURN_STATE)))
|
|
1095
|
+
? OPENPRD_HARNESS_TURN_STATE
|
|
1096
|
+
: (quality?.reportPath ?? null);
|
|
1097
|
+
const knowledgeReview = await reviewKnowledgeWorkspace(projectRoot, {
|
|
1098
|
+
from: knowledgeReviewSource,
|
|
1099
|
+
signal: knowledgeSignal,
|
|
1100
|
+
}).catch((error) => ({
|
|
1101
|
+
ok: false,
|
|
1102
|
+
action: 'quality-knowledge-review',
|
|
1103
|
+
skipped: false,
|
|
1104
|
+
errors: [error instanceof Error ? error.message : String(error)],
|
|
1105
|
+
}));
|
|
1106
|
+
await appendText(harnessPath(projectRoot, LOOP_PROGRESS), renderProgressEntry(timestamp(), [
|
|
1107
|
+
`已完成 ${task.id}: ${task.title}。`,
|
|
1108
|
+
`自测: ${finishResult.verification?.ok ? '通过' : '未运行'}。`,
|
|
1109
|
+
task.oracle ? `对照基准: ${task.oracle}。` : null,
|
|
1110
|
+
`测试报告: ${testReport.markdownPath}。`,
|
|
1111
|
+
`HTML 回归报告: ${testReport.htmlPath}。`,
|
|
1112
|
+
...(quality ? [
|
|
1113
|
+
`最终 EVO: ${quality.report?.readiness?.productionReady ? 'production-ready' : 'needs-attention'}。`,
|
|
1114
|
+
...(quality.htmlPath ? [`EVO 报告: ${path.relative(projectRoot, quality.htmlPath)}。`] : []),
|
|
1115
|
+
] : []),
|
|
1116
|
+
...learningProgress,
|
|
1117
|
+
knowledgeReview?.skipped
|
|
1118
|
+
? null
|
|
1119
|
+
: `项目经验草案: ${path.relative(projectRoot, knowledgeReview.files?.draftSkill ?? knowledgeReview.files?.candidateDir ?? '') || '已生成'}。`,
|
|
1120
|
+
commit ? `Commit: ${commit.skipped ? '跳过' : commit.sha}` : 'Commit: 未请求。',
|
|
1121
|
+
]));
|
|
1122
|
+
await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), {
|
|
1123
|
+
version: 1,
|
|
1124
|
+
at: timestamp(),
|
|
1125
|
+
action: 'finish',
|
|
1126
|
+
taskId: task.id,
|
|
1127
|
+
taskHandle: task.taskHandle,
|
|
1128
|
+
taskTitle: task.title,
|
|
1129
|
+
changeId: task.changeId,
|
|
1130
|
+
ok: true,
|
|
1131
|
+
oracle: task.oracle ?? null,
|
|
1132
|
+
commit: commit ? { ok: commit.ok, skipped: commit.skipped, sha: commit.sha ?? null } : null,
|
|
1133
|
+
testReport: testReport.markdownPath,
|
|
1134
|
+
regressionHtml: testReport.htmlPath,
|
|
1135
|
+
quality: quality
|
|
1136
|
+
? {
|
|
1137
|
+
ok: quality.ok,
|
|
1138
|
+
productionReady: quality.report?.readiness?.productionReady ?? null,
|
|
1139
|
+
reportPath: quality.reportPath ?? null,
|
|
1140
|
+
htmlPath: quality.htmlPath ?? null,
|
|
1141
|
+
attentionGates: quality.report?.readiness?.attentionGates ?? [],
|
|
1142
|
+
}
|
|
1143
|
+
: null,
|
|
1144
|
+
knowledgeReview: knowledgeReview?.skipped
|
|
1145
|
+
? {
|
|
1146
|
+
ok: true,
|
|
1147
|
+
skipped: true,
|
|
1148
|
+
reason: knowledgeReview.reason ?? 'skipped',
|
|
1149
|
+
}
|
|
1150
|
+
: knowledgeReview
|
|
1151
|
+
? {
|
|
1152
|
+
ok: knowledgeReview.ok !== false,
|
|
1153
|
+
skipped: false,
|
|
1154
|
+
candidateId: knowledgeReview.candidateId ?? null,
|
|
1155
|
+
draftSkill: knowledgeReview.files?.draftSkill ?? null,
|
|
1156
|
+
candidateDir: knowledgeReview.files?.candidateDir ?? null,
|
|
1157
|
+
}
|
|
1158
|
+
: null,
|
|
1159
|
+
learningReview: learningReview?.ok
|
|
1160
|
+
? {
|
|
1161
|
+
ok: true,
|
|
1162
|
+
skipped: Boolean(learningReview.skipped),
|
|
1163
|
+
packageId: learningReview.packageId ?? null,
|
|
1164
|
+
readerHtml: learningReview.packagePaths?.readerHtml ?? null,
|
|
1165
|
+
}
|
|
1166
|
+
: {
|
|
1167
|
+
ok: false,
|
|
1168
|
+
skipped: false,
|
|
1169
|
+
errors: learningReview?.errors ?? [],
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
await updateLoopState(projectRoot, {
|
|
1173
|
+
currentTaskId: nextAfterFinish?.id ?? null,
|
|
1174
|
+
currentTaskHandle: nextAfterFinish?.taskHandle ?? null,
|
|
1175
|
+
currentTaskTitle: nextAfterFinish?.title ?? null,
|
|
1176
|
+
completedTaskIds: updatedList.tasks.filter((item) => item.status === 'done').map((item) => item.id),
|
|
1177
|
+
});
|
|
1178
|
+
return {
|
|
1179
|
+
ok: true,
|
|
1180
|
+
action: 'loop-finish',
|
|
1181
|
+
projectRoot,
|
|
1182
|
+
task,
|
|
1183
|
+
advanced,
|
|
1184
|
+
change,
|
|
1185
|
+
commit,
|
|
1186
|
+
testReport: testReport.markdownPath,
|
|
1187
|
+
regressionHtml: testReport.htmlPath,
|
|
1188
|
+
quality,
|
|
1189
|
+
knowledgeReview,
|
|
1190
|
+
learningReview,
|
|
1191
|
+
summary: buildLoopSummary(updatedList),
|
|
1192
|
+
next: nextAfterFinish,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export async function runLoopWorkspace(projectRoot, options = {}) {
|
|
1197
|
+
const agent = normalizeAgent(options.agent ?? 'codex');
|
|
1198
|
+
const promptResult = await promptLoopWorkspace(projectRoot, { ...options, agent, mode: 'loop-run' });
|
|
1199
|
+
if (!promptResult.ok) return promptResult;
|
|
1200
|
+
|
|
1201
|
+
const absolutePromptPath = harnessPath(projectRoot, promptResult.promptPath);
|
|
1202
|
+
const prompt = await readText(absolutePromptPath);
|
|
1203
|
+
const invocation = options.agentCommand
|
|
1204
|
+
? {
|
|
1205
|
+
command: options.agentCommand,
|
|
1206
|
+
args: [],
|
|
1207
|
+
stdinFile: promptResult.promptPath,
|
|
1208
|
+
display: `${options.agentCommand} < ${shellJoin([promptResult.promptPath])}`,
|
|
1209
|
+
shell: true,
|
|
1210
|
+
}
|
|
1211
|
+
: defaultAgentInvocation(agent, projectRoot, promptResult.promptPath);
|
|
1212
|
+
|
|
1213
|
+
const sessionEvent = {
|
|
1214
|
+
version: 1,
|
|
1215
|
+
at: timestamp(),
|
|
1216
|
+
action: options.dryRun ? 'run-dry-run' : 'run',
|
|
1217
|
+
agent,
|
|
1218
|
+
taskId: promptResult.task.id,
|
|
1219
|
+
taskHandle: promptResult.task.taskHandle,
|
|
1220
|
+
taskTitle: promptResult.task.title,
|
|
1221
|
+
changeId: promptResult.task.changeId,
|
|
1222
|
+
promptPath: promptResult.promptPath,
|
|
1223
|
+
invocation: invocation.display,
|
|
1224
|
+
};
|
|
1225
|
+
await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), sessionEvent);
|
|
1226
|
+
await updateLoopState(projectRoot, {
|
|
1227
|
+
currentTaskId: promptResult.task.id,
|
|
1228
|
+
currentTaskHandle: promptResult.task.taskHandle,
|
|
1229
|
+
currentTaskTitle: promptResult.task.title,
|
|
1230
|
+
lastAgent: agent,
|
|
1231
|
+
lastSessionAt: sessionEvent.at,
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
if (options.dryRun) {
|
|
1235
|
+
return {
|
|
1236
|
+
ok: true,
|
|
1237
|
+
action: 'loop-run',
|
|
1238
|
+
dryRun: true,
|
|
1239
|
+
projectRoot,
|
|
1240
|
+
agent,
|
|
1241
|
+
task: promptResult.task,
|
|
1242
|
+
promptPath: promptResult.promptPath,
|
|
1243
|
+
invocation,
|
|
1244
|
+
prompt: promptResult.prompt,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const run = invocation.shell
|
|
1249
|
+
? await runCommand(invocation.command, [], { cwd: projectRoot, shell: true, stdin: prompt })
|
|
1250
|
+
: await runCommand(invocation.command, invocation.args, { cwd: projectRoot, stdin: prompt });
|
|
1251
|
+
await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), {
|
|
1252
|
+
version: 1,
|
|
1253
|
+
at: timestamp(),
|
|
1254
|
+
action: 'agent-exit',
|
|
1255
|
+
agent,
|
|
1256
|
+
taskId: promptResult.task.id,
|
|
1257
|
+
taskHandle: promptResult.task.taskHandle,
|
|
1258
|
+
taskTitle: promptResult.task.title,
|
|
1259
|
+
ok: run.ok,
|
|
1260
|
+
status: run.status,
|
|
1261
|
+
});
|
|
1262
|
+
if (!run.ok) {
|
|
1263
|
+
return {
|
|
1264
|
+
ok: false,
|
|
1265
|
+
action: 'loop-run',
|
|
1266
|
+
projectRoot,
|
|
1267
|
+
agent,
|
|
1268
|
+
task: promptResult.task,
|
|
1269
|
+
run,
|
|
1270
|
+
errors: [run.stderr || run.stdout || 'Agent 命令执行失败。'],
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const finish = await finishLoopWorkspace(projectRoot, {
|
|
1275
|
+
item: promptResult.task.id,
|
|
1276
|
+
commit: options.commit,
|
|
1277
|
+
message: options.message ?? promptResult.task.commitMessage,
|
|
1278
|
+
notes: `Finished by openprd loop run --agent ${agent}.`,
|
|
1279
|
+
});
|
|
1280
|
+
return {
|
|
1281
|
+
ok: finish.ok,
|
|
1282
|
+
action: 'loop-run',
|
|
1283
|
+
projectRoot,
|
|
1284
|
+
agent,
|
|
1285
|
+
task: promptResult.task,
|
|
1286
|
+
run,
|
|
1287
|
+
finish,
|
|
1288
|
+
errors: finish.errors ?? [],
|
|
1289
|
+
};
|
|
1290
|
+
}
|