@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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { validateOpenSpecChangeWorkspace, resolveOpenSpecChangeId } from './change-validate.js';
|
|
4
|
+
import { listOpenSpecStructuredTasks } from './tasks.js';
|
|
5
|
+
import { timestamp } from '../time.js';
|
|
6
|
+
import {
|
|
7
|
+
cjoin,
|
|
8
|
+
exists,
|
|
9
|
+
listChangeDirs,
|
|
10
|
+
openPrdAcceptedSpecRoot,
|
|
11
|
+
openPrdArchiveChangeRoot,
|
|
12
|
+
openPrdDiscoveryConfigPath,
|
|
13
|
+
readDiscoveryConfig,
|
|
14
|
+
resolveChangeDir,
|
|
15
|
+
} from './paths.js';
|
|
16
|
+
|
|
17
|
+
async function readText(filePath) {
|
|
18
|
+
return fs.readFile(filePath, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readJson(filePath) {
|
|
22
|
+
const text = await readText(filePath);
|
|
23
|
+
return JSON.parse(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writeText(filePath, text) {
|
|
27
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
28
|
+
await fs.writeFile(filePath, text, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function writeJson(filePath, value) {
|
|
32
|
+
await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function changesStatePath(projectRoot) {
|
|
36
|
+
return cjoin(projectRoot, '.openprd', 'state', 'changes.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readChangesState(projectRoot) {
|
|
40
|
+
return (await readJson(changesStatePath(projectRoot)).catch(() => null)) ?? {
|
|
41
|
+
version: 1,
|
|
42
|
+
activeChange: null,
|
|
43
|
+
changes: {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function writeChangesState(projectRoot, state) {
|
|
48
|
+
await writeJson(changesStatePath(projectRoot), {
|
|
49
|
+
version: 1,
|
|
50
|
+
activeChange: state.activeChange ?? null,
|
|
51
|
+
updatedAt: timestamp(),
|
|
52
|
+
changes: state.changes ?? {},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writeActiveChange(projectRoot, changeId) {
|
|
57
|
+
const state = await readChangesState(projectRoot);
|
|
58
|
+
state.activeChange = changeId;
|
|
59
|
+
state.changes[changeId] = {
|
|
60
|
+
...(state.changes[changeId] ?? {}),
|
|
61
|
+
id: changeId,
|
|
62
|
+
status: 'active',
|
|
63
|
+
activatedAt: timestamp(),
|
|
64
|
+
};
|
|
65
|
+
await writeChangesState(projectRoot, state);
|
|
66
|
+
|
|
67
|
+
const config = await readDiscoveryConfig(projectRoot, readJson);
|
|
68
|
+
await writeJson(openPrdDiscoveryConfigPath(projectRoot), {
|
|
69
|
+
...config,
|
|
70
|
+
activeChange: changeId,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readAcceptedIndex(projectRoot) {
|
|
77
|
+
const indexPath = cjoin(openPrdAcceptedSpecRoot(projectRoot), 'index.json');
|
|
78
|
+
return (await readJson(indexPath).catch(() => null)) ?? {
|
|
79
|
+
version: 1,
|
|
80
|
+
updatedAt: null,
|
|
81
|
+
capabilities: {},
|
|
82
|
+
appliedChanges: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeAcceptedIndex(projectRoot, index) {
|
|
87
|
+
await writeJson(cjoin(openPrdAcceptedSpecRoot(projectRoot), 'index.json'), {
|
|
88
|
+
version: 1,
|
|
89
|
+
updatedAt: timestamp(),
|
|
90
|
+
capabilities: index.capabilities ?? {},
|
|
91
|
+
appliedChanges: index.appliedChanges ?? [],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function collectChangeSpecs(changeDir) {
|
|
96
|
+
const specsRoot = cjoin(changeDir, 'specs');
|
|
97
|
+
const specs = [];
|
|
98
|
+
let entries = [];
|
|
99
|
+
try {
|
|
100
|
+
entries = await fs.readdir(specsRoot, { withFileTypes: true });
|
|
101
|
+
} catch {
|
|
102
|
+
return specs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
106
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const sourcePath = cjoin(specsRoot, entry.name, 'spec.md');
|
|
110
|
+
if (await exists(sourcePath)) {
|
|
111
|
+
specs.push({
|
|
112
|
+
capability: entry.name,
|
|
113
|
+
sourcePath,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return specs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function allStructuredTasksComplete(projectRoot, changeId) {
|
|
122
|
+
const { tasks } = await listOpenSpecStructuredTasks(projectRoot, { changeId });
|
|
123
|
+
if (tasks.length === 0) {
|
|
124
|
+
return { complete: true, total: 0, incomplete: [] };
|
|
125
|
+
}
|
|
126
|
+
const incomplete = tasks.filter((task) => !task.checked);
|
|
127
|
+
return {
|
|
128
|
+
complete: incomplete.length === 0,
|
|
129
|
+
total: tasks.length,
|
|
130
|
+
incomplete: incomplete.map((task) => task.id),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function updateChangeStatus(projectRoot, changeId, status, extra = {}) {
|
|
135
|
+
const state = await readChangesState(projectRoot);
|
|
136
|
+
state.changes[changeId] = {
|
|
137
|
+
...(state.changes[changeId] ?? {}),
|
|
138
|
+
id: changeId,
|
|
139
|
+
status,
|
|
140
|
+
updatedAt: timestamp(),
|
|
141
|
+
...extra,
|
|
142
|
+
};
|
|
143
|
+
if (status === 'active') {
|
|
144
|
+
state.activeChange = changeId;
|
|
145
|
+
}
|
|
146
|
+
if ((status === 'closed' || status === 'archived' || status === 'applied') && state.activeChange === changeId) {
|
|
147
|
+
state.activeChange = null;
|
|
148
|
+
}
|
|
149
|
+
await writeChangesState(projectRoot, state);
|
|
150
|
+
return state;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function listOpenPrdChangesWorkspace(projectRoot) {
|
|
154
|
+
const state = await readChangesState(projectRoot);
|
|
155
|
+
const discoveryConfig = await readDiscoveryConfig(projectRoot, readJson);
|
|
156
|
+
const activeChange = state.activeChange ?? discoveryConfig.activeChange ?? null;
|
|
157
|
+
const changes = await listChangeDirs(projectRoot);
|
|
158
|
+
const rows = [];
|
|
159
|
+
|
|
160
|
+
for (const change of changes) {
|
|
161
|
+
const taskState = await allStructuredTasksComplete(projectRoot, change.id).catch(() => ({ total: 0, incomplete: [] }));
|
|
162
|
+
rows.push({
|
|
163
|
+
id: change.id,
|
|
164
|
+
source: change.source,
|
|
165
|
+
status: state.changes?.[change.id]?.status ?? (change.archived ? 'archived' : 'draft'),
|
|
166
|
+
active: change.id === activeChange,
|
|
167
|
+
archived: change.archived,
|
|
168
|
+
changeDir: change.changeDir,
|
|
169
|
+
taskTotal: taskState.total,
|
|
170
|
+
taskIncomplete: taskState.incomplete.length,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
action: 'list',
|
|
177
|
+
projectRoot,
|
|
178
|
+
activeChange,
|
|
179
|
+
changes: rows,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function activateOpenPrdChangeWorkspace(projectRoot, options = {}) {
|
|
184
|
+
const changeId = await resolveOpenSpecChangeId(projectRoot, options.change);
|
|
185
|
+
const changeDir = await resolveChangeDir(projectRoot, changeId);
|
|
186
|
+
if (!(await exists(changeDir))) {
|
|
187
|
+
throw new Error(`Missing OpenPrd change directory: ${path.relative(projectRoot, changeDir)}`);
|
|
188
|
+
}
|
|
189
|
+
await writeActiveChange(projectRoot, changeId);
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
action: 'activate',
|
|
193
|
+
projectRoot,
|
|
194
|
+
changeId,
|
|
195
|
+
changeDir,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function closeOpenPrdChangeWorkspace(projectRoot, options = {}) {
|
|
200
|
+
const changeId = await resolveOpenSpecChangeId(projectRoot, options.change);
|
|
201
|
+
const state = await updateChangeStatus(projectRoot, changeId, 'closed', {
|
|
202
|
+
closedAt: timestamp(),
|
|
203
|
+
reason: options.notes ?? null,
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
action: 'close',
|
|
208
|
+
projectRoot,
|
|
209
|
+
changeId,
|
|
210
|
+
activeChange: state.activeChange,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function applyOpenPrdChangeWorkspace(projectRoot, options = {}) {
|
|
215
|
+
const changeId = await resolveOpenSpecChangeId(projectRoot, options.change);
|
|
216
|
+
const validation = await validateOpenSpecChangeWorkspace(projectRoot, { change: changeId });
|
|
217
|
+
if (!validation.ok) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
action: 'apply',
|
|
221
|
+
projectRoot,
|
|
222
|
+
changeId,
|
|
223
|
+
validation,
|
|
224
|
+
appliedSpecs: [],
|
|
225
|
+
errors: validation.errors,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const taskState = await allStructuredTasksComplete(projectRoot, changeId);
|
|
230
|
+
if (!taskState.complete && !options.force) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
action: 'apply',
|
|
234
|
+
projectRoot,
|
|
235
|
+
changeId,
|
|
236
|
+
validation,
|
|
237
|
+
appliedSpecs: [],
|
|
238
|
+
errors: [`Change ${changeId} 仍有未完成任务: ${taskState.incomplete.join(', ')}。如需强制应用,请使用 --force。`],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const changeDir = await resolveChangeDir(projectRoot, changeId);
|
|
243
|
+
const specs = await collectChangeSpecs(changeDir);
|
|
244
|
+
const acceptedRoot = openPrdAcceptedSpecRoot(projectRoot);
|
|
245
|
+
const acceptedIndex = await readAcceptedIndex(projectRoot);
|
|
246
|
+
const appliedSpecs = [];
|
|
247
|
+
const now = timestamp();
|
|
248
|
+
|
|
249
|
+
for (const spec of specs) {
|
|
250
|
+
const targetPath = cjoin(acceptedRoot, spec.capability, 'spec.md');
|
|
251
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
252
|
+
await fs.copyFile(spec.sourcePath, targetPath);
|
|
253
|
+
acceptedIndex.capabilities[spec.capability] = {
|
|
254
|
+
capability: spec.capability,
|
|
255
|
+
specPath: path.relative(projectRoot, targetPath),
|
|
256
|
+
sourceChange: changeId,
|
|
257
|
+
appliedAt: now,
|
|
258
|
+
};
|
|
259
|
+
appliedSpecs.push({
|
|
260
|
+
capability: spec.capability,
|
|
261
|
+
specPath: targetPath,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
acceptedIndex.appliedChanges.push({
|
|
266
|
+
changeId,
|
|
267
|
+
appliedAt: now,
|
|
268
|
+
forced: Boolean(options.force),
|
|
269
|
+
capabilities: appliedSpecs.map((spec) => spec.capability),
|
|
270
|
+
});
|
|
271
|
+
await writeAcceptedIndex(projectRoot, acceptedIndex);
|
|
272
|
+
await updateChangeStatus(projectRoot, changeId, 'applied', {
|
|
273
|
+
appliedAt: now,
|
|
274
|
+
forced: Boolean(options.force),
|
|
275
|
+
acceptedSpecCount: appliedSpecs.length,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
action: 'apply',
|
|
281
|
+
projectRoot,
|
|
282
|
+
changeId,
|
|
283
|
+
validation,
|
|
284
|
+
appliedSpecs,
|
|
285
|
+
acceptedRoot,
|
|
286
|
+
taskState,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function archiveOpenPrdChangeWorkspace(projectRoot, options = {}) {
|
|
291
|
+
const changeId = await resolveOpenSpecChangeId(projectRoot, options.change);
|
|
292
|
+
const changeDir = await resolveChangeDir(projectRoot, changeId);
|
|
293
|
+
if (!(await exists(changeDir))) {
|
|
294
|
+
throw new Error(`Missing OpenPrd change directory: ${path.relative(projectRoot, changeDir)}`);
|
|
295
|
+
}
|
|
296
|
+
const archiveDir = cjoin(openPrdArchiveChangeRoot(projectRoot), changeId);
|
|
297
|
+
if (await exists(archiveDir)) {
|
|
298
|
+
if (!options.force) {
|
|
299
|
+
throw new Error(`Archived change already exists: ${path.relative(projectRoot, archiveDir)}. Use --force to overwrite.`);
|
|
300
|
+
}
|
|
301
|
+
await fs.rm(archiveDir, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
await fs.mkdir(path.dirname(archiveDir), { recursive: true });
|
|
304
|
+
await fs.cp(changeDir, archiveDir, { recursive: true });
|
|
305
|
+
if (!options.keep) {
|
|
306
|
+
await fs.rm(changeDir, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
await updateChangeStatus(projectRoot, changeId, 'archived', {
|
|
309
|
+
archivedAt: timestamp(),
|
|
310
|
+
archiveDir: path.relative(projectRoot, archiveDir),
|
|
311
|
+
sourceDir: path.relative(projectRoot, changeDir),
|
|
312
|
+
keptSource: Boolean(options.keep),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
action: 'archive',
|
|
318
|
+
projectRoot,
|
|
319
|
+
changeId,
|
|
320
|
+
archiveDir,
|
|
321
|
+
removedSource: !options.keep,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function listAcceptedSpecsWorkspace(projectRoot) {
|
|
326
|
+
const acceptedRoot = openPrdAcceptedSpecRoot(projectRoot);
|
|
327
|
+
const index = await readAcceptedIndex(projectRoot);
|
|
328
|
+
const specs = [];
|
|
329
|
+
|
|
330
|
+
let entries = [];
|
|
331
|
+
try {
|
|
332
|
+
entries = await fs.readdir(acceptedRoot, { withFileTypes: true });
|
|
333
|
+
} catch {
|
|
334
|
+
entries = [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
338
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const specPath = cjoin(acceptedRoot, entry.name, 'spec.md');
|
|
342
|
+
if (await exists(specPath)) {
|
|
343
|
+
specs.push({
|
|
344
|
+
capability: entry.name,
|
|
345
|
+
specPath,
|
|
346
|
+
metadata: index.capabilities?.[entry.name] ?? null,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
action: 'specs',
|
|
354
|
+
projectRoot,
|
|
355
|
+
acceptedRoot,
|
|
356
|
+
specs,
|
|
357
|
+
appliedChanges: index.appliedChanges ?? [],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { listChangeDirs, readDiscoveryConfig, resolveChangeDir } from './paths.js';
|
|
5
|
+
import { analyzeOpenSpecTaskVolumes } from './tasks.js';
|
|
6
|
+
import { checkStandardsWorkspace } from '../standards.js';
|
|
7
|
+
import { findOpenPrdSpecLanguageViolations } from '../language-policy.js';
|
|
8
|
+
|
|
9
|
+
function cjoin(...parts) {
|
|
10
|
+
return path.join(...parts);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function exists(filePath) {
|
|
14
|
+
return fs.access(filePath).then(() => true).catch(() => false);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readText(filePath) {
|
|
18
|
+
return fs.readFile(filePath, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readYaml(filePath) {
|
|
22
|
+
const text = await readText(filePath);
|
|
23
|
+
const parsed = YAML.parse(text);
|
|
24
|
+
return parsed ?? {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readJson(filePath) {
|
|
28
|
+
const text = await readText(filePath);
|
|
29
|
+
return JSON.parse(text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function resolveOpenSpecChangeId(projectRoot, requestedChange) {
|
|
33
|
+
if (requestedChange) {
|
|
34
|
+
return requestedChange;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const discoveryConfig = await readDiscoveryConfig(projectRoot, readJson);
|
|
38
|
+
if (discoveryConfig?.activeChange) {
|
|
39
|
+
return discoveryConfig.activeChange;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const changeDirs = [...new Set((await listChangeDirs(projectRoot))
|
|
43
|
+
.filter((change) => !change.archived)
|
|
44
|
+
.map((change) => change.id))]
|
|
45
|
+
.sort();
|
|
46
|
+
|
|
47
|
+
if (changeDirs.length === 1) {
|
|
48
|
+
return changeDirs[0];
|
|
49
|
+
}
|
|
50
|
+
if (changeDirs.length === 0) {
|
|
51
|
+
throw new Error('openprd/changes 下没有找到 OpenPrd change。');
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`找到多个 OpenPrd change;请传入 --change <id>。已找到: ${changeDirs.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractProposalCapabilities(text) {
|
|
57
|
+
const capabilities = [];
|
|
58
|
+
for (const match of text.matchAll(/^- `([^`]+)`:/gm)) {
|
|
59
|
+
capabilities.push(match[1]);
|
|
60
|
+
}
|
|
61
|
+
return capabilities;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SPEC_SECTION_HEADING_RE = /^##\s+(?:(ADDED|MODIFIED|REMOVED)\s+Requirements|(?:新增|修改|移除)需求)\s*$/gim;
|
|
65
|
+
const REQUIREMENT_HEADING_RE = /^###\s+(?:Requirement|需求)[::]\s*(.+)$/gm;
|
|
66
|
+
const SCENARIO_HEADING_RE = /^####\s+(?:Scenario|场景)[::]\s*(.+)$/gm;
|
|
67
|
+
const WHEN_STEP_RE = /^-\s+\*\*(?:WHEN|当)\*\*/im;
|
|
68
|
+
const THEN_STEP_RE = /^-\s+\*\*(?:THEN|则|那么)\*\*/im;
|
|
69
|
+
|
|
70
|
+
function validateOpenSpecSpecText(relativePath, text, errors, checks) {
|
|
71
|
+
const requirementMatches = [...text.matchAll(REQUIREMENT_HEADING_RE)];
|
|
72
|
+
const sectionMatches = [...text.matchAll(SPEC_SECTION_HEADING_RE)];
|
|
73
|
+
const languageViolations = findOpenPrdSpecLanguageViolations(text);
|
|
74
|
+
|
|
75
|
+
if (sectionMatches.length === 0) {
|
|
76
|
+
errors.push(`${relativePath} 必须包含“新增需求”“修改需求”或“移除需求”章节。`);
|
|
77
|
+
}
|
|
78
|
+
if (requirementMatches.length === 0) {
|
|
79
|
+
errors.push(`${relativePath} 必须至少包含一个 "### 需求:" 块。`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (let index = 0; index < requirementMatches.length; index += 1) {
|
|
83
|
+
const match = requirementMatches[index];
|
|
84
|
+
const title = match[1].trim();
|
|
85
|
+
const start = match.index ?? 0;
|
|
86
|
+
const end = requirementMatches[index + 1]?.index ?? text.length;
|
|
87
|
+
const block = text.slice(start, end);
|
|
88
|
+
const scenarioMatches = [...block.matchAll(SCENARIO_HEADING_RE)];
|
|
89
|
+
if (scenarioMatches.length === 0) {
|
|
90
|
+
errors.push(`${relativePath} 的需求 "${title}" 必须至少包含一个场景。`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const scenarioMatch of scenarioMatches) {
|
|
95
|
+
const scenarioStart = scenarioMatch.index ?? 0;
|
|
96
|
+
const nextScenario = scenarioMatches.find((candidate) => (candidate.index ?? 0) > scenarioStart);
|
|
97
|
+
const scenarioBlock = block.slice(scenarioStart, nextScenario?.index ?? block.length);
|
|
98
|
+
if (!WHEN_STEP_RE.test(scenarioBlock)) {
|
|
99
|
+
errors.push(`${relativePath} 的场景 "${scenarioMatch[1].trim()}" 缺少“当”。`);
|
|
100
|
+
}
|
|
101
|
+
if (!THEN_STEP_RE.test(scenarioBlock)) {
|
|
102
|
+
errors.push(`${relativePath} 的场景 "${scenarioMatch[1].trim()}" 缺少“则”。`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const violation of languageViolations.slice(0, 5)) {
|
|
108
|
+
errors.push(`${relativePath}:${violation.line} 不符合简体中文规则: ${violation.reason}。除必要专业字段、命令、文件名或英文产品名外,spec.md 正文必须使用简体中文。`);
|
|
109
|
+
}
|
|
110
|
+
if (languageViolations.length > 5) {
|
|
111
|
+
errors.push(`${relativePath} 还有 ${languageViolations.length - 5} 处简体中文规则问题。`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
checks.push(`${relativePath}: ${requirementMatches.length} 个需求。`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function validateOpenSpecChangeWorkspace(projectRoot, options = {}) {
|
|
118
|
+
const changeId = await resolveOpenSpecChangeId(projectRoot, options.change);
|
|
119
|
+
const changeDir = await resolveChangeDir(projectRoot, changeId);
|
|
120
|
+
const errors = [];
|
|
121
|
+
const warnings = [];
|
|
122
|
+
const checks = [];
|
|
123
|
+
|
|
124
|
+
if (!(await exists(changeDir))) {
|
|
125
|
+
errors.push(`缺少 OpenPrd change 目录: ${path.relative(projectRoot, changeDir)}`);
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
valid: false,
|
|
129
|
+
projectRoot,
|
|
130
|
+
changeId,
|
|
131
|
+
changeDir,
|
|
132
|
+
errors,
|
|
133
|
+
warnings,
|
|
134
|
+
checks,
|
|
135
|
+
specs: [],
|
|
136
|
+
taskVolume: { errors: [], checks: [], files: [] },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const metadataPaths = [cjoin(changeDir, '.openprd.yaml'), cjoin(changeDir, '.openspec.yaml')];
|
|
141
|
+
for (const metadataPath of metadataPaths) {
|
|
142
|
+
if (await exists(metadataPath)) {
|
|
143
|
+
await readYaml(metadataPath).catch((error) => {
|
|
144
|
+
errors.push(`${path.relative(projectRoot, metadataPath)} 无效: ${error.message}`);
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const proposalPath = cjoin(changeDir, 'proposal.md');
|
|
151
|
+
let proposalText = '';
|
|
152
|
+
if (!(await exists(proposalPath))) {
|
|
153
|
+
errors.push(`${path.relative(projectRoot, proposalPath)} 是必需文件。`);
|
|
154
|
+
} else {
|
|
155
|
+
proposalText = await readText(proposalPath);
|
|
156
|
+
if (!proposalText.trim()) {
|
|
157
|
+
errors.push(`${path.relative(projectRoot, proposalPath)} 不能为空。`);
|
|
158
|
+
}
|
|
159
|
+
const requiredHeadingGroups = [
|
|
160
|
+
['## 背景与原因', '## Why', '## 为什么'],
|
|
161
|
+
['## 变更内容', '## What Changes'],
|
|
162
|
+
['## 影响范围', '## Impact', '## 影响'],
|
|
163
|
+
];
|
|
164
|
+
for (const headings of requiredHeadingGroups) {
|
|
165
|
+
if (!headings.some((heading) => proposalText.includes(heading))) {
|
|
166
|
+
warnings.push(`${path.relative(projectRoot, proposalPath)} 缺少章节: ${headings[0]}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const designPath = cjoin(changeDir, 'design.md');
|
|
172
|
+
if (!(await exists(designPath))) {
|
|
173
|
+
warnings.push(`${path.relative(projectRoot, designPath)} 缺失;复杂变更建议补充设计依据。`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const specsRoot = cjoin(changeDir, 'specs');
|
|
177
|
+
const specs = [];
|
|
178
|
+
if (!(await exists(specsRoot))) {
|
|
179
|
+
errors.push(`${path.relative(projectRoot, specsRoot)} 是必需目录。`);
|
|
180
|
+
} else {
|
|
181
|
+
const specEntries = await fs.readdir(specsRoot, { withFileTypes: true }).catch(() => []);
|
|
182
|
+
for (const entry of specEntries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
183
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const specPath = cjoin(specsRoot, entry.name, 'spec.md');
|
|
187
|
+
const relativePath = path.relative(projectRoot, specPath);
|
|
188
|
+
if (!(await exists(specPath))) {
|
|
189
|
+
errors.push(`${relativePath} 是必需文件。`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const text = await readText(specPath);
|
|
193
|
+
specs.push({ capability: entry.name, path: specPath, relativePath });
|
|
194
|
+
validateOpenSpecSpecText(relativePath, text, errors, checks);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (specs.length === 0) {
|
|
199
|
+
errors.push(`${path.relative(projectRoot, specsRoot)} 必须至少包含一个 capability spec。`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const proposalCapabilities = extractProposalCapabilities(proposalText);
|
|
203
|
+
for (const capability of proposalCapabilities) {
|
|
204
|
+
if (!specs.some((spec) => spec.capability === capability)) {
|
|
205
|
+
warnings.push(`proposal.md 中的 capability ${capability} 没有对应的 specs/${capability}/spec.md。`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const spec of specs) {
|
|
209
|
+
if (proposalCapabilities.length > 0 && !proposalCapabilities.includes(spec.capability)) {
|
|
210
|
+
warnings.push(`spec capability ${spec.capability} 未列入 proposal.md 的能力范围。`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const taskVolume = await analyzeOpenSpecTaskVolumes(projectRoot, { changeId });
|
|
215
|
+
errors.push(...taskVolume.errors);
|
|
216
|
+
checks.push(...taskVolume.checks);
|
|
217
|
+
|
|
218
|
+
const standards = await checkStandardsWorkspace(projectRoot, {
|
|
219
|
+
optional: !(await exists(cjoin(projectRoot, '.openprd'))),
|
|
220
|
+
sourceManuals: options.sourceManuals,
|
|
221
|
+
docsContent: options.docsContent,
|
|
222
|
+
});
|
|
223
|
+
if (!standards.skipped) {
|
|
224
|
+
errors.push(...standards.errors);
|
|
225
|
+
warnings.push(...standards.warnings);
|
|
226
|
+
checks.push(...standards.checks);
|
|
227
|
+
const hasStandardsTask = taskVolume.files.some((file) => file.text.includes('openprd standards') && file.text.includes('docs/basic'));
|
|
228
|
+
if (!hasStandardsTask) {
|
|
229
|
+
warnings.push(`${path.relative(projectRoot, changeDir)} 应包含 docs/basic 标准文档维护任务。`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
checks.unshift(`OpenPrd change ${changeId}: ${specs.length} 个 spec delta。`);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
ok: errors.length === 0,
|
|
237
|
+
valid: errors.length === 0,
|
|
238
|
+
projectRoot,
|
|
239
|
+
changeId,
|
|
240
|
+
changeDir,
|
|
241
|
+
errors,
|
|
242
|
+
warnings,
|
|
243
|
+
checks,
|
|
244
|
+
specs,
|
|
245
|
+
taskVolume,
|
|
246
|
+
standards,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const OPENPRD_DISCOVERY_DIR = ['.openprd', 'discovery'];
|
|
2
|
+
export const OPENPRD_DISCOVERY_CONFIG_PATH = ['.openprd', 'discovery', 'config.json'];
|
|
3
|
+
export const LEGACY_OPENSPEC_DISCOVERY_DIR = ['.openspec', 'discovery'];
|
|
4
|
+
export const LEGACY_OPENSPEC_DISCOVERY_CONFIG_PATH = ['.openspec', 'discovery', 'config.json'];
|
|
5
|
+
export const OPENSPEC_DISCOVERY_CONFIG_PATH = LEGACY_OPENSPEC_DISCOVERY_CONFIG_PATH;
|
|
6
|
+
export const OPENPRD_CHANGE_ROOT = ['openprd', 'changes'];
|
|
7
|
+
export const LEGACY_OPENSPEC_CHANGE_ROOT = ['openspec', 'changes'];
|
|
8
|
+
export const OPENPRD_ACCEPTED_SPEC_ROOT = ['openprd', 'specs'];
|
|
9
|
+
export const OPENPRD_ARCHIVE_CHANGE_ROOT = ['openprd', 'archive', 'changes'];
|
|
10
|
+
export const OPENSPEC_TASK_MAX_ITEMS_PER_FILE = 25;
|
|
11
|
+
export const OPENSPEC_TASK_FILE_PATTERN = /^tasks(?:-\d{3})?\.md$/;
|
|
12
|
+
export const OPENSPEC_TASK_ID_PATTERN = /^T\d{3}\.\d+$/;
|