@gordon.gan/specflow 1.8.0-beta → 1.8.2-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/cli/commands/document-run.d.ts +98 -0
- package/dist/cli/commands/document-run.js +1064 -0
- package/dist/cli/index.js +2 -2
- package/dist/core/artifact-language.js +11 -1
- package/dist/core/document/asset-paths.d.ts +15 -0
- package/dist/core/document/asset-paths.js +38 -0
- package/dist/core/document/chapters.d.ts +6 -0
- package/dist/core/document/chapters.js +38 -0
- package/dist/core/document/coverage.d.ts +25 -0
- package/dist/core/document/coverage.js +49 -0
- package/dist/core/document/digests.d.ts +40 -0
- package/dist/core/document/digests.js +144 -0
- package/dist/core/document/engine.d.ts +101 -0
- package/dist/core/document/engine.js +722 -0
- package/dist/core/document/extract.d.ts +61 -0
- package/dist/core/document/extract.js +437 -0
- package/dist/core/{approval → document}/forbidden-patterns.d.ts +4 -2
- package/dist/core/document/forbidden-patterns.js +36 -0
- package/dist/core/document/gates.d.ts +39 -0
- package/dist/core/document/gates.js +75 -0
- package/dist/core/document/input-digest.d.ts +63 -0
- package/dist/core/document/input-digest.js +200 -0
- package/dist/core/document/input-features.d.ts +43 -0
- package/dist/core/document/input-features.js +161 -0
- package/dist/core/document/lint.d.ts +39 -0
- package/dist/core/document/lint.js +147 -0
- package/dist/core/document/llm.d.ts +28 -0
- package/dist/core/document/llm.js +22 -0
- package/dist/core/document/map.d.ts +52 -0
- package/dist/core/document/map.js +296 -0
- package/dist/core/document/outline.d.ts +29 -0
- package/dist/core/document/outline.js +171 -0
- package/dist/core/document/paths.d.ts +23 -0
- package/dist/core/document/paths.js +36 -0
- package/dist/core/document/profile-validator.d.ts +23 -0
- package/dist/core/document/profile-validator.js +155 -0
- package/dist/core/document/profiles.d.ts +23 -0
- package/dist/core/document/profiles.js +76 -0
- package/dist/core/document/render.d.ts +61 -0
- package/dist/core/document/render.js +243 -0
- package/dist/core/document/review.d.ts +57 -0
- package/dist/core/document/review.js +202 -0
- package/dist/core/document/scene-detect.d.ts +32 -0
- package/dist/core/document/scene-detect.js +237 -0
- package/dist/core/document/schemas.d.ts +1111 -0
- package/dist/core/document/schemas.js +191 -0
- package/dist/integrations/shared/capability-evidence.js +4 -9
- package/dist/integrations/shared/command-catalog.js +2 -1
- package/dist/integrations/shared/parity-manifest.js +4 -9
- package/package.json +2 -1
- package/prompts/apply/phase-a-plan.md +1 -1
- package/prompts/document/map/acceptance.md +9 -0
- package/prompts/document/map/anti-ai.md +29 -0
- package/prompts/document/map/api-design.md +26 -0
- package/prompts/document/map/architecture.md +27 -0
- package/prompts/document/map/benchmark.md +26 -0
- package/prompts/document/map/closed-loop.md +9 -0
- package/prompts/document/map/compat-migration.md +31 -0
- package/prompts/document/map/component-design.md +30 -0
- package/prompts/document/map/config-runtime.md +9 -0
- package/prompts/document/map/core-flow.md +62 -0
- package/prompts/document/map/core-logic.md +9 -0
- package/prompts/document/map/data-model.md +18 -0
- package/prompts/document/map/deploy.md +26 -0
- package/prompts/document/map/fix.md +8 -0
- package/prompts/document/map/frontend-architecture.md +35 -0
- package/prompts/document/map/goal.md +8 -0
- package/prompts/document/map/impact.md +8 -0
- package/prompts/document/map/implementability.md +9 -0
- package/prompts/document/map/migration-guide.md +36 -0
- package/prompts/document/map/mvp-boundary.md +8 -0
- package/prompts/document/map/non-goals.md +8 -0
- package/prompts/document/map/ops.md +33 -0
- package/prompts/document/map/performance.md +32 -0
- package/prompts/document/map/poc-demo.md +25 -0
- package/prompts/document/map/regression.md +8 -0
- package/prompts/document/map/reproduce.md +8 -0
- package/prompts/document/map/requirement.md +8 -0
- package/prompts/document/map/research.md +25 -0
- package/prompts/document/map/root-cause.md +8 -0
- package/prompts/document/map/signoff.md +9 -0
- package/prompts/document/map/state-management.md +23 -0
- package/prompts/document/map/tech-selection.md +29 -0
- package/prompts/document/map/test-strategy.md +24 -0
- package/prompts/document/map/ui-design.md +21 -0
- package/prompts/document/outline/general.md +46 -0
- package/prompts/document/review/ai-review.md +57 -0
- package/prompts/document/shared/grounding.md +84 -0
- package/prompts/explore/explore-session.md +1 -1
- package/prompts/propose/design-draft.md +1 -1
- package/prompts/propose/proposal.md +1 -1
- package/prompts/propose/specs.md +1 -1
- package/prompts/propose/tasks-draft.md +1 -1
- package/prompts/refine/design-output.md +1 -1
- package/prompts/refine/update-artifacts.md +2 -0
- package/prompts/shared/artifact-language.md +13 -0
- package/skills/specflow-techdoc/SKILL.md +143 -0
- package/skills/specflow-techdoc-synth/SKILL.md +115 -0
- package/templates/document/chapters/acceptance.yaml +16 -0
- package/templates/document/chapters/api-design.yaml +29 -0
- package/templates/document/chapters/architecture.yaml +21 -0
- package/templates/document/chapters/benchmark.yaml +20 -0
- package/templates/document/chapters/closed-loop.yaml +22 -0
- package/templates/document/chapters/compat-migration.yaml +21 -0
- package/templates/document/chapters/component-design.yaml +22 -0
- package/templates/document/chapters/config-runtime.yaml +16 -0
- package/templates/document/chapters/core-flow.yaml +27 -0
- package/templates/document/chapters/core-logic.yaml +16 -0
- package/templates/document/chapters/data-model.yaml +25 -0
- package/templates/document/chapters/deploy.yaml +22 -0
- package/templates/document/chapters/fix.yaml +18 -0
- package/templates/document/chapters/frontend-architecture.yaml +22 -0
- package/templates/document/chapters/goal.yaml +17 -0
- package/templates/document/chapters/impact.yaml +16 -0
- package/templates/document/chapters/implementability.yaml +22 -0
- package/templates/document/chapters/migration-guide.yaml +21 -0
- package/templates/document/chapters/mvp-boundary.yaml +20 -0
- package/templates/document/chapters/non-goals.yaml +17 -0
- package/templates/document/chapters/ops.yaml +25 -0
- package/templates/document/chapters/performance.yaml +21 -0
- package/templates/document/chapters/poc-demo.yaml +22 -0
- package/templates/document/chapters/regression.yaml +16 -0
- package/templates/document/chapters/reproduce.yaml +16 -0
- package/templates/document/chapters/requirement.yaml +18 -0
- package/templates/document/chapters/research.yaml +22 -0
- package/templates/document/chapters/root-cause.yaml +18 -0
- package/templates/document/chapters/signoff.yaml +15 -0
- package/templates/document/chapters/state-management.yaml +22 -0
- package/templates/document/chapters/tech-selection.yaml +23 -0
- package/templates/document/chapters/test-strategy.yaml +23 -0
- package/templates/document/chapters/ui-design.yaml +28 -0
- package/templates/document/profiles/0to1.yaml +44 -0
- package/templates/document/profiles/approve.yaml +22 -0
- package/templates/document/profiles/bugfix.yaml +15 -0
- package/templates/document/profiles/feature.yaml +23 -0
- package/templates/document/profiles/frontend-0to1.yaml +47 -0
- package/templates/document/profiles/migration.yaml +42 -0
- package/templates/document/profiles/poc.yaml +46 -0
- package/dist/cli/commands/approval-assemble.d.ts +0 -64
- package/dist/cli/commands/approval-assemble.js +0 -408
- package/dist/core/approval/assemble.d.ts +0 -10
- package/dist/core/approval/assemble.js +0 -384
- package/dist/core/approval/bundle.d.ts +0 -9
- package/dist/core/approval/bundle.js +0 -172
- package/dist/core/approval/forbidden-patterns.js +0 -40
- package/dist/core/approval/index-schema.d.ts +0 -676
- package/dist/core/approval/index-schema.js +0 -193
- package/dist/core/approval/index.d.ts +0 -12
- package/dist/core/approval/index.js +0 -9
- package/dist/core/approval/lint.d.ts +0 -10
- package/dist/core/approval/lint.js +0 -302
- package/dist/core/approval/paths.d.ts +0 -13
- package/dist/core/approval/paths.js +0 -43
- package/dist/core/approval/pipeline.d.ts +0 -28
- package/dist/core/approval/pipeline.js +0 -146
- package/dist/core/approval/playbook-schema.d.ts +0 -182
- package/dist/core/approval/playbook-schema.js +0 -51
- package/dist/core/approval/render.d.ts +0 -20
- package/dist/core/approval/render.js +0 -210
- package/dist/core/approval/review-pack.d.ts +0 -26
- package/dist/core/approval/review-pack.js +0 -205
- package/dist/core/approval/types.d.ts +0 -223
- package/dist/core/approval/types.js +0 -1
- package/prompts/approval/acp-pipeline.md +0 -106
- package/prompts/approval/ai-review.md +0 -145
- package/prompts/approval/api-guidance.md +0 -179
- package/prompts/approval/database-guidance.md +0 -105
- package/prompts/approval/frontend-guidance.md +0 -249
- package/prompts/approval/generate.md +0 -1596
- package/prompts/approval/multi-repo-guidance.md +0 -263
- package/prompts/approval/multi-repo-spoke-subagent.md +0 -94
- package/prompts/approval/project-conventions-guidance.md +0 -171
- package/prompts/approval/runtime-guidance.md +0 -64
- package/prompts/approval/segmented-generation.md +0 -157
- package/skills/specflow-approval/SKILL.md +0 -778
- package/templates/approval-index.yaml +0 -93
- package/templates/approval-part.md +0 -15
- package/templates/approval-playbook-talos-scenario-job-compile.yaml +0 -29
- package/templates/approval-playbook.yaml +0 -28
- package/templates/approval.md +0 -261
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: specflow techdoc {plan|validate|run} — Generic LongDoc Engine, IDE-model driven.
|
|
3
|
+
*
|
|
4
|
+
* The engine does NOT call a third-party LLM API. In Cursor/Codex the Agent runs on the IDE's
|
|
5
|
+
* model, so:
|
|
6
|
+
* - `document plan <profile>` emits a step list the Agent executes with the IDE model.
|
|
7
|
+
* - `document validate --work-root <path>` deterministically validates + renders Agent-written
|
|
8
|
+
* artifacts (outline.yaml / chapters/*.md / entities.json) — no LLM call.
|
|
9
|
+
* - `document run <profile>` guides: plan → Agent executes → validate (requires injected LLM only
|
|
10
|
+
* for library/tests; CLI itself never fetches an API).
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import { join, basename, dirname, sep } from 'node:path';
|
|
14
|
+
import { loadChapterLibrary } from '../../core/document/chapters.js';
|
|
15
|
+
import { loadProfile } from '../../core/document/profiles.js';
|
|
16
|
+
import { runDocument, planSteps, validateWork } from '../../core/document/engine.js';
|
|
17
|
+
import { detectInputFeatures } from '../../core/document/input-features.js';
|
|
18
|
+
import { detectScene, confirmationPrompt } from '../../core/document/scene-detect.js';
|
|
19
|
+
import { reposPath } from '../../core/document/paths.js';
|
|
20
|
+
import { readOutline } from '../../core/document/outline.js';
|
|
21
|
+
import { readWorksetsState, defaultWorksetsStatePath } from '../../core/worksets.js';
|
|
22
|
+
import { getGlobalDataDir } from '../../core/global-config.js';
|
|
23
|
+
async function readInputFile(path, type) {
|
|
24
|
+
const content = await fs.readFile(path, 'utf-8');
|
|
25
|
+
return { type, source: path, content };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 解析 --max-rounds(P2):commander 以字符串传入,'abc'/'0' 会静默旁路 Pass 3 审查。
|
|
29
|
+
* 非法或 <1 → 抛错(由命令顶层 catch 转为 exit 1),避免质量门禁被悄悄跳过。
|
|
30
|
+
*/
|
|
31
|
+
function parseMaxRounds(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return 3;
|
|
34
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
35
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
36
|
+
throw new Error(`Invalid --max-rounds "${value}": must be a positive integer`);
|
|
37
|
+
}
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
async function collectInputs(options) {
|
|
41
|
+
const inputs = [];
|
|
42
|
+
if (options.text)
|
|
43
|
+
inputs.push({ type: 'description', source: 'description', content: options.text });
|
|
44
|
+
if (options.proposal)
|
|
45
|
+
inputs.push(await readInputFile(options.proposal, 'proposal'));
|
|
46
|
+
if (options.design)
|
|
47
|
+
inputs.push(await readInputFile(options.design, 'design'));
|
|
48
|
+
if (options.specs)
|
|
49
|
+
inputs.push(await readInputFile(options.specs, 'specs'));
|
|
50
|
+
if (options.tasks)
|
|
51
|
+
inputs.push(await readInputFile(options.tasks, 'tasks'));
|
|
52
|
+
if (options.code)
|
|
53
|
+
inputs.push(await readInputFile(options.code, 'code'));
|
|
54
|
+
return inputs;
|
|
55
|
+
}
|
|
56
|
+
function inputFeatures(inputs, opts) {
|
|
57
|
+
// Content-based detection (deterministic keyword scoring), plus the explicit --signoff flag.
|
|
58
|
+
// Previously `uiInScope` was hardcoded false and `hasLogic` always true here, which made
|
|
59
|
+
// `ui-design` unreachable and skipped conditional chapters regardless of the input.
|
|
60
|
+
return {
|
|
61
|
+
...detectInputFeatures(inputs),
|
|
62
|
+
includeSignoff: opts?.includeSignoff ?? false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function stepsToMarkdown(steps, profileId) {
|
|
66
|
+
const lines = [
|
|
67
|
+
`# 文档生成步骤(${profileId})`,
|
|
68
|
+
'',
|
|
69
|
+
'> 请你在当前 IDE 会话中用模型逐步执行以下步骤,产物写入 workRoot。',
|
|
70
|
+
'> 完成后运行 `specflow techdoc validate --work-root <path>` 校验。',
|
|
71
|
+
'',
|
|
72
|
+
];
|
|
73
|
+
for (const s of steps) {
|
|
74
|
+
lines.push(`## ${s.id}(${s.phase})`, '');
|
|
75
|
+
lines.push(`**做什么**:${s.description}`, '');
|
|
76
|
+
lines.push(`**参考提示词**:\`${s.prompt}\``, '');
|
|
77
|
+
if (s.inputFiles.length)
|
|
78
|
+
lines.push(`**输入**:${s.inputFiles.join(', ')}`, '');
|
|
79
|
+
lines.push(`**输出**:\`${s.outputFile}\``, '');
|
|
80
|
+
lines.push(`**必做**:${s.required ? '是' : '否(可选)'}`, '');
|
|
81
|
+
lines.push('', '---', '');
|
|
82
|
+
}
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
// ============= document plan =============
|
|
86
|
+
export async function planDocumentCommand(profileId, options) {
|
|
87
|
+
try {
|
|
88
|
+
const library = await loadChapterLibrary();
|
|
89
|
+
const profile = await loadProfile(profileId);
|
|
90
|
+
const inputs = await collectInputs(options);
|
|
91
|
+
const workRoot = options.workRoot ?? join(process.cwd(), '.specflow', 'document');
|
|
92
|
+
await fs.mkdir(workRoot, { recursive: true });
|
|
93
|
+
const result = planSteps({
|
|
94
|
+
profile,
|
|
95
|
+
library,
|
|
96
|
+
inputFeatures: inputFeatures(inputs, { includeSignoff: options.signoff }),
|
|
97
|
+
inputs,
|
|
98
|
+
workRoot,
|
|
99
|
+
projectRoot: process.cwd(),
|
|
100
|
+
});
|
|
101
|
+
const payload = {
|
|
102
|
+
ok: true,
|
|
103
|
+
profile: profileId,
|
|
104
|
+
workRoot,
|
|
105
|
+
chapters: result.chapters,
|
|
106
|
+
steps: result.steps,
|
|
107
|
+
};
|
|
108
|
+
if (options.json) {
|
|
109
|
+
return { exitCode: 0, payload };
|
|
110
|
+
}
|
|
111
|
+
if (options.markdown) {
|
|
112
|
+
console.info(stepsToMarkdown(result.steps, profileId));
|
|
113
|
+
return { exitCode: 0, payload };
|
|
114
|
+
}
|
|
115
|
+
console.info(`文档生成步骤(${profileId}),workRoot: ${workRoot}`);
|
|
116
|
+
console.info('');
|
|
117
|
+
for (const s of result.steps) {
|
|
118
|
+
console.info(` [${s.phase}] ${s.id}: ${s.title}`);
|
|
119
|
+
console.info(` → ${s.outputFile}`);
|
|
120
|
+
}
|
|
121
|
+
console.info('');
|
|
122
|
+
console.info('请用 IDE 模型按上述步骤生成产物,然后运行:');
|
|
123
|
+
console.info(` specflow techdoc validate --work-root ${workRoot}`);
|
|
124
|
+
return { exitCode: 0, payload };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
if (options.json)
|
|
129
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
130
|
+
console.error(`Error: ${message}`);
|
|
131
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ============= document validate =============
|
|
135
|
+
export async function validateDocumentCommand(options) {
|
|
136
|
+
try {
|
|
137
|
+
const workRoot = options.workRoot ?? join(process.cwd(), '.specflow', 'document');
|
|
138
|
+
const library = await loadChapterLibrary();
|
|
139
|
+
// Detect profile from outline.yaml (or require it). 复用 readOutline 的 YAML/JSON 双格式
|
|
140
|
+
// 解析(P2):手写正则探测对 JSON 格式/带引号 profile 会误判。
|
|
141
|
+
const outline = await readOutline(workRoot);
|
|
142
|
+
const profile = await loadProfile(outline.profile);
|
|
143
|
+
const result = await validateWork({
|
|
144
|
+
profile,
|
|
145
|
+
library,
|
|
146
|
+
workRoot,
|
|
147
|
+
renderHtmlOutput: !options.noRender,
|
|
148
|
+
});
|
|
149
|
+
const payload = {
|
|
150
|
+
ok: result.ok,
|
|
151
|
+
workRoot,
|
|
152
|
+
document: result.documentMd ? join(workRoot, 'document.md') : undefined,
|
|
153
|
+
html: result.documentHtml ? join(workRoot, 'document.html') : undefined,
|
|
154
|
+
diagnostics: result.diagnostics,
|
|
155
|
+
};
|
|
156
|
+
if (options.json) {
|
|
157
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
158
|
+
}
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
console.error(`Document validation failed:`);
|
|
161
|
+
for (const d of result.diagnostics) {
|
|
162
|
+
console.error(` [${d.gate}][${d.severity}] ${d.message}`);
|
|
163
|
+
}
|
|
164
|
+
return { exitCode: 1, payload };
|
|
165
|
+
}
|
|
166
|
+
console.info(`Document valid: ${join(workRoot, 'document.md')}`);
|
|
167
|
+
if (result.documentHtml)
|
|
168
|
+
console.info(`HTML: ${join(workRoot, 'document.html')}`);
|
|
169
|
+
return { exitCode: 0, payload };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
173
|
+
if (options.json)
|
|
174
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
175
|
+
console.error(`Error: ${message}`);
|
|
176
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ============= document run (Agent-driven guide; library `runDocument` needs an injected LLM) =============
|
|
180
|
+
let injectedLLM = null;
|
|
181
|
+
export function injectLLM(llm) {
|
|
182
|
+
injectedLLM = llm;
|
|
183
|
+
}
|
|
184
|
+
function getInjectedLLM() {
|
|
185
|
+
return injectedLLM;
|
|
186
|
+
}
|
|
187
|
+
export async function runDocumentCommand(profileId, options) {
|
|
188
|
+
try {
|
|
189
|
+
// Collect inputs first (needed for scene auto-detection).
|
|
190
|
+
const inputs = await collectInputs(options);
|
|
191
|
+
if (inputs.length === 0) {
|
|
192
|
+
const msg = 'No input provided. Use --text or --proposal/--design/--specs/--code.';
|
|
193
|
+
if (options.json)
|
|
194
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
195
|
+
console.error(`Error: ${msg}`);
|
|
196
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
197
|
+
}
|
|
198
|
+
const text = inputs.map((i) => i.content).join('\n');
|
|
199
|
+
// Scene resolution: explicit profile, or auto-detect from natural language; confirm when uncertain.
|
|
200
|
+
let resolvedProfile = profileId ?? '';
|
|
201
|
+
if (!resolvedProfile) {
|
|
202
|
+
const det = detectScene(text);
|
|
203
|
+
if (det.profile && det.confidence >= 0.5) {
|
|
204
|
+
resolvedProfile = det.profile;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const msg = confirmationPrompt(det, text) +
|
|
208
|
+
'\n\n确认后重新运行,例如:\n specflow techdoc run <profile> --text "..."';
|
|
209
|
+
const payload = { ok: false, needsConfirmation: true, candidates: det.candidates, message: msg };
|
|
210
|
+
if (options.json)
|
|
211
|
+
return { exitCode: 0, payload };
|
|
212
|
+
console.info(msg);
|
|
213
|
+
return { exitCode: 0, payload: { ok: false, needsConfirmation: true } };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// One-click entry: no injected LLM → emit the Agent execution directive. The Agent (running on
|
|
217
|
+
// the IDE model) reads this and auto-completes plan → generate → validate. CLI itself never fetches an API.
|
|
218
|
+
const llm = getInjectedLLM();
|
|
219
|
+
if (!llm) {
|
|
220
|
+
const workRoot = options.workRoot ?? join(process.cwd(), '.specflow', 'document');
|
|
221
|
+
await fs.mkdir(workRoot, { recursive: true });
|
|
222
|
+
const library = await loadChapterLibrary();
|
|
223
|
+
const profile = await loadProfile(resolvedProfile);
|
|
224
|
+
const plan = planSteps({ profile, library, inputFeatures: inputFeatures(inputs, { includeSignoff: options.signoff }), inputs, workRoot, projectRoot: process.cwd() });
|
|
225
|
+
const directive = [
|
|
226
|
+
`# 一键生成技术文档(${resolvedProfile})`,
|
|
227
|
+
'',
|
|
228
|
+
`请作为 Agent 用当前 IDE 模型自动完成以下步骤(勿中途停下来等用户分步),产物写入 workRoot: ${workRoot}`,
|
|
229
|
+
'',
|
|
230
|
+
`## 步骤`,
|
|
231
|
+
...plan.steps.map((s) => `- [${s.phase}] **${s.id}**: ${s.title} → 输出 \`${s.outputFile}\`${s.required ? '' : '(可选)'}`),
|
|
232
|
+
'',
|
|
233
|
+
`## 完成后`,
|
|
234
|
+
`运行 \`specflow techdoc validate --work-root ${workRoot}\`,通过后向用户报告 document.md 路径;失败则修正产物后重跑。`,
|
|
235
|
+
'',
|
|
236
|
+
`提示词模板参考(IDE 资产):`,
|
|
237
|
+
`- outline: .claude/specflow/prompts/document/outline/general.md`,
|
|
238
|
+
`- map: .claude/specflow/prompts/document/map/<章节>.md`,
|
|
239
|
+
`- review: .claude/specflow/prompts/document/review/ai-review.md`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
const payload = { ok: true, mode: 'agent-directive', workRoot, steps: plan.steps };
|
|
242
|
+
if (options.json)
|
|
243
|
+
return { exitCode: 0, payload };
|
|
244
|
+
console.info(directive);
|
|
245
|
+
return { exitCode: 0, payload };
|
|
246
|
+
}
|
|
247
|
+
// Delegates to the library runDocument for completeness (tests use this).
|
|
248
|
+
try {
|
|
249
|
+
const library = await loadChapterLibrary();
|
|
250
|
+
const profile = await loadProfile(resolvedProfile);
|
|
251
|
+
const workRoot = options.workRoot ?? join(process.cwd(), '.specflow', 'document');
|
|
252
|
+
await fs.mkdir(workRoot, { recursive: true });
|
|
253
|
+
const result = await runDocument({
|
|
254
|
+
llm,
|
|
255
|
+
workRoot,
|
|
256
|
+
profile,
|
|
257
|
+
library,
|
|
258
|
+
inputs,
|
|
259
|
+
inputFeatures: inputFeatures(inputs, { includeSignoff: options.signoff }),
|
|
260
|
+
change: resolvedProfile,
|
|
261
|
+
skipReview: options.skipReview ?? false,
|
|
262
|
+
maxRounds: parseMaxRounds(options.maxRounds),
|
|
263
|
+
renderHtmlOutput: false,
|
|
264
|
+
persist: true,
|
|
265
|
+
projectRoot: process.cwd(),
|
|
266
|
+
});
|
|
267
|
+
const payload = {
|
|
268
|
+
ok: result.ok,
|
|
269
|
+
stage: result.stage,
|
|
270
|
+
workRoot,
|
|
271
|
+
document: result.documentMd ? join(workRoot, 'document.md') : undefined,
|
|
272
|
+
diagnostics: result.diagnostics,
|
|
273
|
+
};
|
|
274
|
+
if (options.json)
|
|
275
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
276
|
+
if (!result.ok) {
|
|
277
|
+
console.error(`Document generation failed at stage "${result.stage}":`);
|
|
278
|
+
for (const d of result.diagnostics)
|
|
279
|
+
console.error(` [${d.gate}][${d.severity}] ${d.message}`);
|
|
280
|
+
return { exitCode: 1, payload };
|
|
281
|
+
}
|
|
282
|
+
console.info(`Document generated (${resolvedProfile}) in ${workRoot}`);
|
|
283
|
+
return { exitCode: 0, payload };
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
287
|
+
if (options.json)
|
|
288
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
289
|
+
console.error(`Error: ${message}`);
|
|
290
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
// 顶层兜底:collectInputs(文件不存在)/ loadProfile / planSteps / 场景识别等
|
|
295
|
+
// 任一失败都不再以未捕获 rejection 崩溃,--json 契约始终成立(P0-4)。
|
|
296
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
297
|
+
if (options.json)
|
|
298
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
299
|
+
console.error(`Error: ${message}`);
|
|
300
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// ============= document detect =============
|
|
304
|
+
export async function detectDocumentCommand(options) {
|
|
305
|
+
try {
|
|
306
|
+
const inputs = await collectInputs(options);
|
|
307
|
+
const text = inputs.map((i) => i.content).join('\n');
|
|
308
|
+
const det = detectScene(text);
|
|
309
|
+
const payload = {
|
|
310
|
+
ok: true,
|
|
311
|
+
detected: det.profile,
|
|
312
|
+
confidence: det.confidence,
|
|
313
|
+
candidates: det.candidates,
|
|
314
|
+
reasons: det.reasons,
|
|
315
|
+
};
|
|
316
|
+
if (options.json)
|
|
317
|
+
return { exitCode: 0, payload };
|
|
318
|
+
if (det.profile) {
|
|
319
|
+
console.info(`检测到场景: ${det.profile}(置信度 ${Math.round(det.confidence * 100)}%)`);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
console.info(confirmationPrompt(det, text));
|
|
323
|
+
}
|
|
324
|
+
return { exitCode: 0, payload };
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
328
|
+
if (options.json)
|
|
329
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
330
|
+
console.error(`Error: ${message}`);
|
|
331
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Discover multi-repo four-artifact sets under a workspace root.
|
|
336
|
+
* A repo contributes if `<repoRoot>/specflow/changes/<change>/` contains proposal/specs/design/tasks.
|
|
337
|
+
*
|
|
338
|
+
* spec 支持两种写法:
|
|
339
|
+
* - "repo1:change1,repo2:change2":精确指定 change(原行为)
|
|
340
|
+
* - "repo1,repo2":只写仓名 → 自动选该仓最相关 change(有 text 时按描述匹配,否则最新)
|
|
341
|
+
*/
|
|
342
|
+
async function discoverRepoChanges(workspaceRoot, spec, text) {
|
|
343
|
+
const repos = [];
|
|
344
|
+
if (spec) {
|
|
345
|
+
for (const part of spec.split(',')) {
|
|
346
|
+
const trimmed = part.trim();
|
|
347
|
+
if (!trimmed)
|
|
348
|
+
continue;
|
|
349
|
+
const [repo, change] = trimmed.split(':');
|
|
350
|
+
if (!repo)
|
|
351
|
+
continue;
|
|
352
|
+
const root = join(workspaceRoot, repo);
|
|
353
|
+
if (change) {
|
|
354
|
+
repos.push({ repo, change, root });
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// repo-only → auto-select the best change: text-matched, else latest non-archive
|
|
358
|
+
const matched = text ? await matchRepoChangeByText(root, text) : null;
|
|
359
|
+
const best = matched?.change ?? (await latestRepoChange(root));
|
|
360
|
+
if (best)
|
|
361
|
+
repos.push({ repo, change: best, root });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return repos;
|
|
365
|
+
}
|
|
366
|
+
// Auto-scan: workspaceRoot/<repo>/specflow/changes/*/
|
|
367
|
+
const entries = await fs.readdir(workspaceRoot, { withFileTypes: true });
|
|
368
|
+
for (const e of entries) {
|
|
369
|
+
if (!e.isDirectory())
|
|
370
|
+
continue;
|
|
371
|
+
const changesDir = join(workspaceRoot, e.name, 'specflow', 'changes');
|
|
372
|
+
try {
|
|
373
|
+
const changes = await fs.readdir(changesDir);
|
|
374
|
+
for (const ch of changes) {
|
|
375
|
+
if (ch.startsWith('archive'))
|
|
376
|
+
continue;
|
|
377
|
+
repos.push({ repo: e.name, change: ch, root: join(workspaceRoot, e.name) });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// no specflow/changes in this dir → skip
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return repos;
|
|
385
|
+
}
|
|
386
|
+
/** 某仓最新(mtime 最大)的非 archive change 名;无四件套或目录缺失 → null。 */
|
|
387
|
+
async function latestRepoChange(repoRoot) {
|
|
388
|
+
const changesDir = join(repoRoot, 'specflow', 'changes');
|
|
389
|
+
let entries;
|
|
390
|
+
try {
|
|
391
|
+
entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
let latest = null;
|
|
397
|
+
for (const e of entries) {
|
|
398
|
+
if (!e.isDirectory() || e.name.startsWith('archive'))
|
|
399
|
+
continue;
|
|
400
|
+
try {
|
|
401
|
+
const st = await fs.stat(join(changesDir, e.name));
|
|
402
|
+
if (!latest || st.mtimeMs > latest.mtimeMs)
|
|
403
|
+
latest = { name: e.name, mtimeMs: st.mtimeMs };
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// unreadable entry → skip
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return latest?.name ?? null;
|
|
410
|
+
}
|
|
411
|
+
/** 提取描述文本的匹配 token:英文/数字词(小写)+ 中文 2-gram。用于与 change 摘要做相关性打分。 */
|
|
412
|
+
function extractMatchTokens(text) {
|
|
413
|
+
const tokens = new Set();
|
|
414
|
+
for (const m of text.toLowerCase().matchAll(/[a-z0-9][a-z0-9_-]*/g)) {
|
|
415
|
+
if (m[0].length >= 2)
|
|
416
|
+
tokens.add(m[0]);
|
|
417
|
+
}
|
|
418
|
+
const cjk = text.replace(/[^\u4e00-\u9fff]/g, '');
|
|
419
|
+
for (let i = 0; i + 1 < cjk.length; i++)
|
|
420
|
+
tokens.add(cjk.slice(i, i + 2));
|
|
421
|
+
return tokens;
|
|
422
|
+
}
|
|
423
|
+
/** 读某 change 的提案摘要(proposal.md 优先,缺则 design.md / spec.md),用于文本匹配。 */
|
|
424
|
+
async function readChangeSummary(changeDir) {
|
|
425
|
+
for (const file of ['proposal.md', 'design.md', 'spec.md']) {
|
|
426
|
+
try {
|
|
427
|
+
return (await fs.readFile(join(changeDir, file), 'utf-8')).slice(0, 2000);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// try next artifact
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return '';
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* 按 --text 描述匹配某仓最相关的 change:
|
|
437
|
+
* 1. 对每个非 archive change 读提案摘要(proposal.md 优先,缺则 design.md / spec.md);
|
|
438
|
+
* 2. 打分 = 描述 token 在摘要中的**出现次数**(频率加权,区分"提了一次"与"通篇主题")
|
|
439
|
+
* + change 名直接命中加分(英文 token 与 kebab 名直接匹配,中文 2-gram 亦可子串命中);
|
|
440
|
+
* 3. 返回命中最多的 change;最高命中为 0(描述与该仓任何 change 都无关联)→ null(回退「最新」)。
|
|
441
|
+
* 并列时取 mtime 较新者,保证确定性。
|
|
442
|
+
*/
|
|
443
|
+
async function matchRepoChangeByText(repoRoot, text) {
|
|
444
|
+
const changesDir = join(repoRoot, 'specflow', 'changes');
|
|
445
|
+
let entries;
|
|
446
|
+
try {
|
|
447
|
+
entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const queryTokens = [...extractMatchTokens(text)];
|
|
453
|
+
if (queryTokens.length === 0)
|
|
454
|
+
return null;
|
|
455
|
+
let best = null;
|
|
456
|
+
for (const e of entries) {
|
|
457
|
+
if (!e.isDirectory() || e.name.startsWith('archive'))
|
|
458
|
+
continue;
|
|
459
|
+
const changeDir = join(changesDir, e.name);
|
|
460
|
+
const summary = `${e.name}\n${await readChangeSummary(changeDir)}`.toLowerCase();
|
|
461
|
+
let score = 0;
|
|
462
|
+
for (const t of queryTokens) {
|
|
463
|
+
const re = new RegExp(escapeRegExp(t), 'g');
|
|
464
|
+
const matches = summary.match(re);
|
|
465
|
+
if (matches)
|
|
466
|
+
score += matches.length;
|
|
467
|
+
// change 名命中加分(如 kebab 名 scenario-job-compile 对 token 'scenario'/'job')
|
|
468
|
+
if (e.name.toLowerCase().includes(t))
|
|
469
|
+
score += 5;
|
|
470
|
+
}
|
|
471
|
+
let mtimeMs = 0;
|
|
472
|
+
try {
|
|
473
|
+
mtimeMs = (await fs.stat(changeDir)).mtimeMs;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// unreadable → treat as 0
|
|
477
|
+
}
|
|
478
|
+
if (!best || score > best.score || (score === best.score && mtimeMs > best.mtimeMs)) {
|
|
479
|
+
best = { change: e.name, score, mtimeMs };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return best && best.score > 0 ? { change: best.change, score: best.score } : null;
|
|
483
|
+
}
|
|
484
|
+
/** 转义正则特殊字符(供频率计数用)。 */
|
|
485
|
+
function escapeRegExp(s) {
|
|
486
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* 从已存 workset 推导多仓合成输入:workspace-root(成员公共父目录)+ 每仓 change。
|
|
490
|
+
* 成员路径直接指向各仓根目录,因此不需要用户再手写长路径。
|
|
491
|
+
* change 选择优先级:
|
|
492
|
+
* 1. --changes 显式指定(支持 "repo1:ch1" 或只写仓名 "repo1");
|
|
493
|
+
* 2. --text 描述 → 按文本相关性匹配各仓最相关的 change(读提案摘要打分,命中 0 回退最新);
|
|
494
|
+
* 3. 均无 → 每仓最新非 archive change。
|
|
495
|
+
*/
|
|
496
|
+
async function resolveWorksetRepos(worksetName, spec, text, worksetsStatePath = defaultWorksetsStatePath(getGlobalDataDir())) {
|
|
497
|
+
const state = await readWorksetsState(worksetsStatePath);
|
|
498
|
+
const workset = state.worksets.find((w) => w.name === worksetName);
|
|
499
|
+
if (!workset) {
|
|
500
|
+
throw new Error(`Workset '${worksetName}' not found. Run 'specflow workset create <name>' first (members point at each repo root).`);
|
|
501
|
+
}
|
|
502
|
+
if (workset.members.length < 2) {
|
|
503
|
+
throw new Error(`Workset '${worksetName}' has only ${workset.members.length} member(s); synthesize requires ≥2 repos.`);
|
|
504
|
+
}
|
|
505
|
+
// workspace-root = 成员路径的公共父目录(各成员须位于同一工作区下)。
|
|
506
|
+
const parents = workset.members.map((m) => dirname(m.path));
|
|
507
|
+
const workspaceRoot = commonAncestor(parents) ?? dirname(workset.members[0].path);
|
|
508
|
+
const dirs = [];
|
|
509
|
+
if (spec) {
|
|
510
|
+
// 显式 --changes:仓名可用成员 label 或目录名(basename);不带 change 时自动选最新。
|
|
511
|
+
for (const part of spec.split(',')) {
|
|
512
|
+
const trimmed = part.trim();
|
|
513
|
+
if (!trimmed)
|
|
514
|
+
continue;
|
|
515
|
+
const [repo, change] = trimmed.split(':');
|
|
516
|
+
const member = workset.members.find((m) => m.name === repo || basename(m.path) === repo);
|
|
517
|
+
if (!member) {
|
|
518
|
+
throw new Error(`--changes 引用了不在 workset '${worksetName}' 中的仓 '${repo}'。可用仓:${workset.members.map((m) => m.name).join(', ')}`);
|
|
519
|
+
}
|
|
520
|
+
const repoName = basename(member.path);
|
|
521
|
+
const changeName = change ?? (await latestRepoChange(member.path));
|
|
522
|
+
if (!changeName) {
|
|
523
|
+
throw new Error(`Workset member '${member.name}' (${member.path}) has no non-archive change.`);
|
|
524
|
+
}
|
|
525
|
+
dirs.push({ repo: repoName, change: changeName, root: member.path });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
// 无 --changes:有 --text → 按文本相关性匹配每仓最相关 change;无 --text 或匹配不上 → 每仓最新。
|
|
530
|
+
for (const m of workset.members) {
|
|
531
|
+
const repoName = basename(m.path);
|
|
532
|
+
const matched = text ? await matchRepoChangeByText(m.path, text) : null;
|
|
533
|
+
const change = matched?.change ?? (await latestRepoChange(m.path));
|
|
534
|
+
if (change)
|
|
535
|
+
dirs.push({ repo: repoName, change, root: m.path });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return { workspaceRoot, dirs };
|
|
539
|
+
}
|
|
540
|
+
/** 一组绝对路径的公共父目录(按 / 分段取最长公共前缀)。 */
|
|
541
|
+
function commonAncestor(paths) {
|
|
542
|
+
if (paths.length === 0)
|
|
543
|
+
return null;
|
|
544
|
+
const parts = paths.map((p) => p.split(sep));
|
|
545
|
+
let common = parts[0];
|
|
546
|
+
for (const p of parts.slice(1)) {
|
|
547
|
+
let i = 0;
|
|
548
|
+
while (i < common.length && i < p.length && common[i] === p[i])
|
|
549
|
+
i++;
|
|
550
|
+
common = common.slice(0, i);
|
|
551
|
+
if (common.length === 0)
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return common.join(sep);
|
|
555
|
+
}
|
|
556
|
+
export async function approveDocumentCommand(options) {
|
|
557
|
+
try {
|
|
558
|
+
const library = await loadChapterLibrary();
|
|
559
|
+
const profile = await loadProfile('approve');
|
|
560
|
+
const dirs = [];
|
|
561
|
+
if (options.workspaceRoot) {
|
|
562
|
+
dirs.push(...await discoverRepoChanges(options.workspaceRoot, options.changes ?? ''));
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
const change = options.change ?? 'approval';
|
|
566
|
+
dirs.push({ repo: 'local', change, root: process.cwd() });
|
|
567
|
+
}
|
|
568
|
+
if (dirs.length === 0) {
|
|
569
|
+
const msg = 'No four-artifact change found. Provide --proposal/--specs/--design/--tasks or --workspace-root.';
|
|
570
|
+
if (options.json)
|
|
571
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
572
|
+
console.error(`Error: ${msg}`);
|
|
573
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
574
|
+
}
|
|
575
|
+
// Per-repo approve plan (read four artifacts, emit Agent generation steps).
|
|
576
|
+
const plans = [];
|
|
577
|
+
for (const d of dirs) {
|
|
578
|
+
const changeDir = join(d.root, 'specflow', 'changes', d.change);
|
|
579
|
+
const inputs = [];
|
|
580
|
+
for (const [type, file] of [['proposal', 'proposal.md'], ['specs', 'spec.md'], ['design', 'design.md'], ['tasks', 'tasks.md']]) {
|
|
581
|
+
try {
|
|
582
|
+
const content = await fs.readFile(join(changeDir, file), 'utf-8');
|
|
583
|
+
inputs.push({ type, source: join(changeDir, file), content });
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// optional missing artifact → skip
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (inputs.length === 0) {
|
|
590
|
+
// fall back to explicit options (four artifacts)
|
|
591
|
+
if (options.proposal)
|
|
592
|
+
inputs.push(await readInputFile(options.proposal, 'proposal'));
|
|
593
|
+
if (options.specs)
|
|
594
|
+
inputs.push(await readInputFile(options.specs, 'specs'));
|
|
595
|
+
if (options.design)
|
|
596
|
+
inputs.push(await readInputFile(options.design, 'design'));
|
|
597
|
+
if (options.tasks)
|
|
598
|
+
inputs.push(await readInputFile(options.tasks, 'tasks'));
|
|
599
|
+
}
|
|
600
|
+
if (inputs.length === 0) {
|
|
601
|
+
// 空输入拦截(P2):该仓四件套缺失且无显式选项时禁止基于零输入生成。
|
|
602
|
+
const msg = `Repo ${d.repo}/${d.change}: no four-artifact inputs found. Provide --proposal/--specs/--design/--tasks or ensure artifacts exist.`;
|
|
603
|
+
if (options.json)
|
|
604
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
605
|
+
console.error(`Error: ${msg}`);
|
|
606
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
607
|
+
}
|
|
608
|
+
// 多仓模式:显式 --work-root 下按 repo 派生子目录,避免各仓产物互相覆盖(P2)。
|
|
609
|
+
const workRoot = options.workRoot
|
|
610
|
+
? join(options.workRoot, d.repo)
|
|
611
|
+
: join(changeDir, 'approval');
|
|
612
|
+
await fs.mkdir(workRoot, { recursive: true });
|
|
613
|
+
const plan = planSteps({
|
|
614
|
+
profile,
|
|
615
|
+
library,
|
|
616
|
+
inputFeatures: inputFeatures(inputs, { includeSignoff: options.signoff }),
|
|
617
|
+
inputs,
|
|
618
|
+
workRoot,
|
|
619
|
+
projectRoot: d.root,
|
|
620
|
+
});
|
|
621
|
+
plans.push({ repo: d.repo, change: d.change, steps: plan.steps, workRoot });
|
|
622
|
+
}
|
|
623
|
+
// Multi-repo bundle: merge each repo's generated document.md into one approval-unified.md.
|
|
624
|
+
if (options.bundle) {
|
|
625
|
+
if (plans.length < 2) {
|
|
626
|
+
const msg = '--bundle requires multiple repos (--workspace-root or --changes with ≥2).';
|
|
627
|
+
if (options.json)
|
|
628
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
629
|
+
console.error(`Error: ${msg}`);
|
|
630
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
631
|
+
}
|
|
632
|
+
const sections = [`# 审批合订(多仓)`, '', `> 各仓审批文档合订视图(只读)。各仓 document.md 为真源。`, ''];
|
|
633
|
+
const errors = [];
|
|
634
|
+
for (const p of plans) {
|
|
635
|
+
const docPath = join(p.workRoot, 'document.md');
|
|
636
|
+
try {
|
|
637
|
+
const content = await fs.readFile(docPath, 'utf-8');
|
|
638
|
+
sections.push(`---`, '', `## 来源 · ${p.repo}(\`${p.change}\`)`, '', content.trim(), '');
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
errors.push(`${p.repo}/${p.change} document.md 未生成(请先完成该仓生成并 validate)`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (errors.length > 0) {
|
|
645
|
+
const msg = `合订失败:\n ${errors.join('\n ')}`;
|
|
646
|
+
if (options.json)
|
|
647
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
648
|
+
console.error(msg);
|
|
649
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
650
|
+
}
|
|
651
|
+
const unifiedPath = join(plans[0].workRoot, 'approval-unified.md');
|
|
652
|
+
await fs.writeFile(unifiedPath, sections.join('\n'), 'utf-8');
|
|
653
|
+
const payload = { ok: true, mode: 'bundle', unifiedPath, sources: plans.map((p) => ({ repo: p.repo, change: p.change, document: join(p.workRoot, 'document.md') })) };
|
|
654
|
+
if (options.json)
|
|
655
|
+
return { exitCode: 0, payload };
|
|
656
|
+
console.info(`多仓合订完成: ${unifiedPath}`);
|
|
657
|
+
return { exitCode: 0, payload };
|
|
658
|
+
}
|
|
659
|
+
// Build directive.
|
|
660
|
+
const lines = ['# 审批文档生成(approve · 四件套 → 审批)', ''];
|
|
661
|
+
lines.push('请作为 Agent 用当前 IDE 模型,对每个仓的四件套生成审批文档(含闭环检查/可实施性/质量红线),产物写入各仓 workRoot,完成后 validate。');
|
|
662
|
+
lines.push('');
|
|
663
|
+
for (const p of plans) {
|
|
664
|
+
lines.push(`## 仓 ${p.repo}(change: ${p.change})· workRoot: ${p.workRoot}`);
|
|
665
|
+
for (const s of p.steps) {
|
|
666
|
+
lines.push(`- [${s.phase}] **${s.id}**: ${s.title} → \`${s.outputFile}\``);
|
|
667
|
+
}
|
|
668
|
+
lines.push(` - 完成后: \`specflow techdoc validate --work-root ${p.workRoot}\``);
|
|
669
|
+
lines.push('');
|
|
670
|
+
}
|
|
671
|
+
if (plans.length > 1) {
|
|
672
|
+
lines.push('## 多仓合订(主仓)');
|
|
673
|
+
lines.push('各仓审批文档生成并 validate 通过后,主仓汇总为统一视图(只读合订):');
|
|
674
|
+
lines.push(` - 合并各仓 document.md 为 approval-unified.md(主仓: ${plans[0].repo})`);
|
|
675
|
+
}
|
|
676
|
+
const payload = { ok: true, mode: 'approve', plans: plans.map((p) => ({ repo: p.repo, change: p.change, workRoot: p.workRoot, steps: p.steps })) };
|
|
677
|
+
if (options.json)
|
|
678
|
+
return { exitCode: 0, payload };
|
|
679
|
+
console.info(lines.join('\n'));
|
|
680
|
+
return { exitCode: 0, payload };
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
684
|
+
if (options.json)
|
|
685
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
686
|
+
console.error(`Error: ${message}`);
|
|
687
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* 读取一个仓的四件套,返回带来源标记 + repo 字段的输入。
|
|
692
|
+
* - content 前置 `## 来源仓:<repo>(change: <change>)`:压缩器/大纲/章节填充可见归属。
|
|
693
|
+
* - `repo` 字段:段 id 携带仓前缀(`seg-repo1-proposal-01`),Pass 2 按需取回对应仓原文。
|
|
694
|
+
*/
|
|
695
|
+
async function collectRepoInputs(d) {
|
|
696
|
+
const changeDir = join(d.root, 'specflow', 'changes', d.change);
|
|
697
|
+
const marker = `## 来源仓:${d.repo}(change: ${d.change})`;
|
|
698
|
+
const inputs = [];
|
|
699
|
+
for (const [type, file] of [['proposal', 'proposal.md'], ['specs', 'spec.md'], ['design', 'design.md'], ['tasks', 'tasks.md']]) {
|
|
700
|
+
try {
|
|
701
|
+
const content = await fs.readFile(join(changeDir, file), 'utf-8');
|
|
702
|
+
inputs.push({ type, source: join(changeDir, file), content: `${marker}\n\n${content}`, repo: d.repo });
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
// optional missing artifact → skip
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return inputs;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 跨仓合成规则(directive 与库形态 outlinePrompt 共用同一套文本)。
|
|
712
|
+
* 规则 6(按需加载规约):写某仓章节时只读取该仓的规约/IDE rules,禁止一次性读全部仓——
|
|
713
|
+
* 防止 token 暴涨 + 注意力稀释;仅写跨仓内容(依赖/契约对接)时才读取涉及仓的规约。
|
|
714
|
+
*/
|
|
715
|
+
function buildSynthRules(dirs) {
|
|
716
|
+
const repoList = dirs.map((d) => d.repo).join('、');
|
|
717
|
+
return [
|
|
718
|
+
`## 跨仓合成规则(必须遵守)`,
|
|
719
|
+
`1. **统一大纲**:一次生成一份 outline,章节覆盖所有仓,不按仓分章(跨仓对比/汇总放进对应章节)。`,
|
|
720
|
+
`2. **契约实体命名空间**:跨仓实体 id 用 \`<repo>_<id>\` 前缀(如 \`repo1_I1\`、\`repo2_T1\`),禁止不同仓共用裸 id。`,
|
|
721
|
+
`3. **每章必须分仓小节(质量门禁,validate 强制)**:每个必选章节的叙述按仓分节,用 \`### <repo>\` 小节标题(可带后缀如 \`### repo1 · 目标\`),且每个仓(${repoList})都必须出现。禁止把多仓内容混写成一段(会导致细节互相稀释)。`,
|
|
722
|
+
`4. **全局视角**(若所选 profile 包含对应章节):goal 写多仓总目标与边界;tech-selection 对比各仓技术方案;api-design/data-model 分仓列契约;closed-loop 跨仓统一闭环检查;implementability 跨仓统一可实施性评估。其他场景章节(如 reproduce/root-cause/fix、requirement/test-strategy、research/poc-demo/benchmark)同样按仓分节并给出跨仓结论。`,
|
|
723
|
+
`5. **跨仓依赖/风险**:涉及仓间依赖(接口调用/数据共享/发布顺序)时显式标注「跨仓依赖」。`,
|
|
724
|
+
`6. **按需加载规约(重要)**:写某个仓的小节时,**只读取该仓**的规约/IDE rules/guidance(该仓的 \`docs/\`、\`.cursor\`、\`.claude\`、\`.agents\` 等),**禁止一次性读取全部仓的规约**;只有写跨仓内容(接口对接/数据共享/发布顺序)时才读取**涉及仓**的规约。未读取的仓规约不得臆测,必要时标注「未读取该仓规约,待确认」。`,
|
|
725
|
+
`7. **输入归属**:每段输入带「来源仓」标记,段 id 携带仓前缀(如 \`seg-repo1-proposal-01\`);引用原文时按段 id 精确取回对应仓的片段,不整篇重读。`,
|
|
726
|
+
].join('\n');
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* 解析合成用 profile:显式 --profile 优先;省略时从 --text(或四件套拼接内容)做场景识别。
|
|
730
|
+
* - 识别置信度达标 → 直接采用识别结果。
|
|
731
|
+
* - 识别不出且用户给了 --text:
|
|
732
|
+
* - workset 模式(weakDefault 传 true)→ 取分数最高的候选 profile 兜底(如 feature),不打断流程;
|
|
733
|
+
* - 否则 → 返回 needsConfirmation(与 document run 行为一致,向用户确认)。
|
|
734
|
+
* - 识别不出且无 --text → 回退 approve(原 synthesize 默认,保持向后兼容,不打断流程)。
|
|
735
|
+
*/
|
|
736
|
+
function resolveSynthProfile(options, inputs, weakDefault = false) {
|
|
737
|
+
if (options.profile)
|
|
738
|
+
return { profileId: options.profile };
|
|
739
|
+
const detectText = (options.text ?? inputs.map((i) => i.content).join('\n')).trim();
|
|
740
|
+
const det = detectScene(detectText);
|
|
741
|
+
if (det.profile && det.confidence >= 0.5) {
|
|
742
|
+
return { profileId: det.profile, detectedText: detectText };
|
|
743
|
+
}
|
|
744
|
+
if (options.text) {
|
|
745
|
+
if (weakDefault) {
|
|
746
|
+
// workset 模式:描述模糊时不打断流程——取分数最高的候选兜底(0 分则 feature)。
|
|
747
|
+
const top = det.candidates.filter((c) => c.profile !== 'frontend-0to1').sort((a, b) => b.score - a.score)[0];
|
|
748
|
+
const profileId = top && top.score > 0 ? top.profile : 'feature';
|
|
749
|
+
return { profileId, detectedText: detectText, weakDefault: true };
|
|
750
|
+
}
|
|
751
|
+
const message = confirmationPrompt(det, detectText) +
|
|
752
|
+
'\n\n确认后重新运行,例如:\n specflow techdoc synthesize --workspace-root <root> --profile <profile> ...';
|
|
753
|
+
return {
|
|
754
|
+
profileId: null,
|
|
755
|
+
detectedText: detectText,
|
|
756
|
+
needsConfirmation: { candidates: det.candidates, message },
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
// 无 --text:四件套无强场景信号 → 回退 approve(原默认),不打断流程。
|
|
760
|
+
return { profileId: 'approve', detectedText: detectText, fallback: true };
|
|
761
|
+
}
|
|
762
|
+
export async function synthesizeDocumentCommand(options) {
|
|
763
|
+
try {
|
|
764
|
+
// 输入来源解析:--workset 优先(从已存 workset 推导 workspaceRoot + 每仓最新 change);
|
|
765
|
+
// 其次 --workspace-root(可配 --changes 精确指定或只写仓名);两者皆无 → 报错。
|
|
766
|
+
let workspaceRoot = options.workspaceRoot;
|
|
767
|
+
let dirs = [];
|
|
768
|
+
if (options.workset) {
|
|
769
|
+
const resolved = await resolveWorksetRepos(options.workset, options.changes, options.text);
|
|
770
|
+
workspaceRoot = resolved.workspaceRoot;
|
|
771
|
+
dirs = resolved.dirs;
|
|
772
|
+
}
|
|
773
|
+
else if (options.workspaceRoot) {
|
|
774
|
+
dirs = await discoverRepoChanges(options.workspaceRoot, options.changes ?? '', options.text);
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
const msg = 'synthesize 需要 --workspace-root <path> 或 --workset <name>(多仓工作区根目录 / 已存 workset)。';
|
|
778
|
+
if (options.json)
|
|
779
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
780
|
+
console.error(`Error: ${msg}`);
|
|
781
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
782
|
+
}
|
|
783
|
+
if (dirs.length === 0) {
|
|
784
|
+
const msg = 'No multi-repo four-artifact sets found. Use --workspace-root with --changes "repo1:ch1,repo2:ch2" (or repo-only "repo1,repo2"), or --workset <name>.';
|
|
785
|
+
if (options.json)
|
|
786
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
787
|
+
console.error(`Error: ${msg}`);
|
|
788
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
789
|
+
}
|
|
790
|
+
if (dirs.length < 2) {
|
|
791
|
+
const msg = 'synthesize 需要 ≥2 个仓(跨仓合成);单仓请用 document run。';
|
|
792
|
+
if (options.json)
|
|
793
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
794
|
+
console.error(`Error: ${msg}`);
|
|
795
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
796
|
+
}
|
|
797
|
+
// 统一输入集:可选 --text 描述 + 各仓四件套(带来源仓标记)。
|
|
798
|
+
const inputs = [];
|
|
799
|
+
if (options.text)
|
|
800
|
+
inputs.push({ type: 'description', source: 'description', content: options.text });
|
|
801
|
+
for (const d of dirs) {
|
|
802
|
+
inputs.push(...await collectRepoInputs(d));
|
|
803
|
+
}
|
|
804
|
+
// 空输入拦截(P2):四件套全缺且无 --text 时禁止基于零输入生成,与 run 命令行为一致。
|
|
805
|
+
if (inputs.length === 0) {
|
|
806
|
+
const msg = 'No input found: 各仓四件套(proposal/spec/design/tasks)均缺失,且未提供 --text。请先补齐四件套或提供 --text 描述。';
|
|
807
|
+
if (options.json)
|
|
808
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
809
|
+
console.error(`Error: ${msg}`);
|
|
810
|
+
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
811
|
+
}
|
|
812
|
+
// 场景识别:显式 --profile 优先;省略时从 --text / 四件套内容识别。
|
|
813
|
+
// workset 模式描述模糊 → weakDefault 兜底(取分数最高候选,不打断);workspace-root 模式不确定 → 向用户确认。
|
|
814
|
+
const resolved = resolveSynthProfile(options, inputs, !!options.workset);
|
|
815
|
+
if (resolved.needsConfirmation) {
|
|
816
|
+
const payload = {
|
|
817
|
+
ok: false,
|
|
818
|
+
needsConfirmation: true,
|
|
819
|
+
candidates: resolved.needsConfirmation.candidates,
|
|
820
|
+
message: resolved.needsConfirmation.message,
|
|
821
|
+
};
|
|
822
|
+
if (options.json)
|
|
823
|
+
return { exitCode: 0, payload };
|
|
824
|
+
console.info(resolved.needsConfirmation.message);
|
|
825
|
+
return { exitCode: 0, payload };
|
|
826
|
+
}
|
|
827
|
+
const profileId = resolved.profileId;
|
|
828
|
+
const scene = options.profile
|
|
829
|
+
? { profile: profileId, source: 'explicit' }
|
|
830
|
+
: resolved.fallback
|
|
831
|
+
? { profile: profileId, source: 'fallback' }
|
|
832
|
+
: resolved.weakDefault
|
|
833
|
+
? { profile: profileId, source: 'weak-default' }
|
|
834
|
+
: { profile: profileId, source: 'detected' };
|
|
835
|
+
const library = await loadChapterLibrary();
|
|
836
|
+
const profile = await loadProfile(profileId);
|
|
837
|
+
const workRoot = options.workRoot ?? join(workspaceRoot, '.specflow', 'document-synthesized');
|
|
838
|
+
await fs.mkdir(workRoot, { recursive: true });
|
|
839
|
+
// Multi-repo synthesize marker: validateWork reads this to enforce the per-repo section gate.
|
|
840
|
+
// 只有 synthesize 写 repos.json;approve --bundle / document run 不写 → 门禁不触发。
|
|
841
|
+
const repoNames = dirs.map((d) => d.repo);
|
|
842
|
+
await fs.writeFile(reposPath(workRoot), JSON.stringify(repoNames, null, 2), 'utf-8');
|
|
843
|
+
const llm = getInjectedLLM();
|
|
844
|
+
const synthRules = buildSynthRules(dirs);
|
|
845
|
+
if (!llm) {
|
|
846
|
+
// Agent-directive mode (CLI never fetches an LLM API — the Agent uses the IDE model).
|
|
847
|
+
// 不传 projectRoot=workspaceRoot:按需加载——规约由规则 6 要求按「章节涉及的仓」读取,
|
|
848
|
+
// 禁止一次性读取全部仓的规约/IDE rules(防止 token 暴涨 + 注意力稀释)。
|
|
849
|
+
const plan = planSteps({
|
|
850
|
+
profile,
|
|
851
|
+
library,
|
|
852
|
+
inputFeatures: inputFeatures(inputs, { includeSignoff: options.signoff }),
|
|
853
|
+
inputs,
|
|
854
|
+
workRoot,
|
|
855
|
+
});
|
|
856
|
+
const repoList = dirs.map((d) => `${d.repo}/${d.change}`).join('、');
|
|
857
|
+
const selectionNote = options.text && !options.changes
|
|
858
|
+
? `\n> change 按 --text 描述文本相关性自动匹配(未显式 --changes);如需精确指定请重跑并加 --changes "repo1:ch1,repo2:ch2"。`
|
|
859
|
+
: '';
|
|
860
|
+
const sceneLine = scene.source === 'detected'
|
|
861
|
+
? `(场景识别:${scene.profile})`
|
|
862
|
+
: scene.source === 'weak-default'
|
|
863
|
+
? `(场景识别较弱,默认采用 ${scene.profile})`
|
|
864
|
+
: scene.source === 'fallback'
|
|
865
|
+
? '(场景未识别,回退 approve)'
|
|
866
|
+
: '';
|
|
867
|
+
const directive = [
|
|
868
|
+
`# 跨仓合成文档(${profile.id} · ${dirs.length} 仓${sceneLine})`,
|
|
869
|
+
'',
|
|
870
|
+
`请作为 Agent 用当前 IDE 模型自动完成以下步骤(勿中途停下等用户分步),产物写入 workRoot: ${workRoot}`,
|
|
871
|
+
'',
|
|
872
|
+
`## 参与仓与 change:${repoList}${selectionNote}`,
|
|
873
|
+
'',
|
|
874
|
+
`## 输入来源(多仓统一输入,每段已标记「来源仓」,段 id 携带仓前缀)`,
|
|
875
|
+
...inputs.map((i) => `- \`${i.source}\`(${i.type} · 来自 ${i.content.split('\n')[0] ?? ''})`),
|
|
876
|
+
'',
|
|
877
|
+
synthRules,
|
|
878
|
+
'',
|
|
879
|
+
`## 步骤`,
|
|
880
|
+
...plan.steps.map((s) => `- [${s.phase}] **${s.id}**: ${s.title} → 输出 \`${s.outputFile}\`${s.required ? '' : '(可选)'}`),
|
|
881
|
+
'',
|
|
882
|
+
`## 完成后`,
|
|
883
|
+
`运行 \`specflow techdoc validate --work-root ${workRoot}\`,通过后向用户报告 document.md 路径;失败则修正产物后重跑。`,
|
|
884
|
+
'',
|
|
885
|
+
`提示词模板参考(IDE 资产):`,
|
|
886
|
+
`- outline: .claude/specflow/prompts/document/outline/general.md`,
|
|
887
|
+
`- map: .claude/specflow/prompts/document/map/<章节>.md`,
|
|
888
|
+
`- review: .claude/specflow/prompts/document/review/ai-review.md`,
|
|
889
|
+
].join('\n');
|
|
890
|
+
const payload = { ok: true, mode: 'synthesize-directive', profile: profile.id, scene, repos: repoList, workRoot, steps: plan.steps };
|
|
891
|
+
if (options.json)
|
|
892
|
+
return { exitCode: 0, payload };
|
|
893
|
+
console.info(directive);
|
|
894
|
+
return { exitCode: 0, payload };
|
|
895
|
+
}
|
|
896
|
+
// Library mode (tests/hosts inject an LLM): run the full pipeline once on the merged inputs.
|
|
897
|
+
// outlinePrompt 注入跨仓规则(Pass 1 统一大纲 + 命名空间 + 分仓小节 + 全局视角 + 按需读规约),
|
|
898
|
+
// 与 directive 模式同一套规则,避免库形态裸跑丢失跨仓约束。
|
|
899
|
+
try {
|
|
900
|
+
const result = await runDocument({
|
|
901
|
+
llm,
|
|
902
|
+
workRoot,
|
|
903
|
+
profile,
|
|
904
|
+
library,
|
|
905
|
+
inputs,
|
|
906
|
+
inputFeatures: inputFeatures(inputs, { includeSignoff: options.signoff }),
|
|
907
|
+
change: `synthesized:${dirs.map((d) => d.repo).join('+')}`,
|
|
908
|
+
maxRounds: options.signoff ? 3 : 2,
|
|
909
|
+
renderHtmlOutput: false,
|
|
910
|
+
persist: true,
|
|
911
|
+
outlinePrompt: synthRules,
|
|
912
|
+
projectRoot: options.workspaceRoot,
|
|
913
|
+
});
|
|
914
|
+
const payload = {
|
|
915
|
+
ok: result.ok,
|
|
916
|
+
stage: result.stage,
|
|
917
|
+
workRoot,
|
|
918
|
+
profile: profile.id,
|
|
919
|
+
scene,
|
|
920
|
+
document: result.documentMd ? join(workRoot, 'document.md') : undefined,
|
|
921
|
+
diagnostics: result.diagnostics,
|
|
922
|
+
repos: dirs.map((d) => `${d.repo}/${d.change}`),
|
|
923
|
+
};
|
|
924
|
+
if (options.json)
|
|
925
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
926
|
+
if (!result.ok) {
|
|
927
|
+
console.error(`Document synthesis failed at stage "${result.stage}":`);
|
|
928
|
+
for (const d of result.diagnostics)
|
|
929
|
+
console.error(` [${d.gate}][${d.severity}] ${d.message}`);
|
|
930
|
+
return { exitCode: 1, payload };
|
|
931
|
+
}
|
|
932
|
+
console.info(`跨仓合成文档已生成(${dirs.length} 仓,${profile.id}): ${join(workRoot, 'document.md')}`);
|
|
933
|
+
return { exitCode: 0, payload };
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
937
|
+
if (options.json)
|
|
938
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
939
|
+
console.error(`Error: ${message}`);
|
|
940
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
// 顶层兜底(P2):discoverRepoChanges/loadProfile/fs.mkdir/fs.writeFile 等
|
|
945
|
+
// 任一步骤失败不再以未捕获 rejection 崩溃,--json 契约始终成立。
|
|
946
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
947
|
+
if (options.json)
|
|
948
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
949
|
+
console.error(`Error: ${message}`);
|
|
950
|
+
return { exitCode: 1, payload: { ok: false, error: message } };
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// ============= registration =============
|
|
954
|
+
export function registerDocumentRunCommand(program) {
|
|
955
|
+
const doc = program
|
|
956
|
+
.command('techdoc')
|
|
957
|
+
.description('Generate scenario-based solution documents (Generic LongDoc Engine)');
|
|
958
|
+
doc
|
|
959
|
+
.command('detect')
|
|
960
|
+
.description('Auto-detect the scene profile (0to1 | frontend-0to1 | bugfix | feature | poc | migration) from natural language')
|
|
961
|
+
.option('--text <text>', 'Free-text problem description')
|
|
962
|
+
.option('--proposal <path>', 'Proposal input file')
|
|
963
|
+
.option('--design <path>', 'Design input file')
|
|
964
|
+
.option('--specs <path>', 'Specs input file')
|
|
965
|
+
.option('--code <path>', 'Code input file')
|
|
966
|
+
.option('--json', 'Output detection as JSON')
|
|
967
|
+
.action(async (opts) => {
|
|
968
|
+
const { exitCode, payload } = await detectDocumentCommand(opts);
|
|
969
|
+
if (exitCode !== 0)
|
|
970
|
+
process.exitCode = exitCode;
|
|
971
|
+
if (opts.json)
|
|
972
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
973
|
+
});
|
|
974
|
+
doc
|
|
975
|
+
.command('plan <profile>')
|
|
976
|
+
.description('Emit the Agent-executable step list (IDE model does the work; no LLM API call)')
|
|
977
|
+
.option('--text <text>', 'Free-text problem description')
|
|
978
|
+
.option('--proposal <path>', 'Proposal input file')
|
|
979
|
+
.option('--design <path>', 'Design input file')
|
|
980
|
+
.option('--specs <path>', 'Specs input file')
|
|
981
|
+
.option('--code <path>', 'Code input file')
|
|
982
|
+
.option('--work-root <path>', 'Work directory (default: .specflow/document)')
|
|
983
|
+
.option('--json', 'Output step list as JSON')
|
|
984
|
+
.option('--markdown', 'Output step list as Markdown')
|
|
985
|
+
.action(async (profile, opts) => {
|
|
986
|
+
const { exitCode, payload } = await planDocumentCommand(profile, opts);
|
|
987
|
+
if (exitCode !== 0)
|
|
988
|
+
process.exitCode = exitCode;
|
|
989
|
+
if (opts.json)
|
|
990
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
991
|
+
});
|
|
992
|
+
doc
|
|
993
|
+
.command('validate')
|
|
994
|
+
.description('Deterministically validate + render Agent-written artifacts (no LLM)')
|
|
995
|
+
.option('--work-root <path>', 'Work directory (default: .specflow/document)')
|
|
996
|
+
.option('--json', 'Output result as JSON')
|
|
997
|
+
.option('--no-render', 'Skip HTML rendering')
|
|
998
|
+
.action(async (opts) => {
|
|
999
|
+
const { exitCode, payload } = await validateDocumentCommand(opts);
|
|
1000
|
+
if (exitCode !== 0)
|
|
1001
|
+
process.exitCode = exitCode;
|
|
1002
|
+
if (opts.json)
|
|
1003
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
1004
|
+
});
|
|
1005
|
+
doc
|
|
1006
|
+
.command('run [profile]')
|
|
1007
|
+
.description('One-click generate a scenario document. Omit <profile> to auto-detect scene; confirm when uncertain.')
|
|
1008
|
+
.option('--text <text>', 'Free-text problem description')
|
|
1009
|
+
.option('--proposal <path>', 'Proposal input file')
|
|
1010
|
+
.option('--design <path>', 'Design input file')
|
|
1011
|
+
.option('--specs <path>', 'Specs input file')
|
|
1012
|
+
.option('--code <path>', 'Code input file')
|
|
1013
|
+
.option('--work-root <path>', 'Work directory (default: .specflow/document)')
|
|
1014
|
+
.option('--signoff', 'Include an approval sign-off section')
|
|
1015
|
+
.option('--skip-review', 'Skip Pass 3 AI review')
|
|
1016
|
+
.option('--max-rounds <n>', 'AI review max rounds', '3')
|
|
1017
|
+
.option('--json', 'Output result as JSON')
|
|
1018
|
+
.action(async (profile, opts) => {
|
|
1019
|
+
const { exitCode, payload } = await runDocumentCommand(profile, opts);
|
|
1020
|
+
if (exitCode !== 0)
|
|
1021
|
+
process.exitCode = exitCode;
|
|
1022
|
+
if (opts.json)
|
|
1023
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
1024
|
+
});
|
|
1025
|
+
doc
|
|
1026
|
+
.command('synthesize')
|
|
1027
|
+
.description('Synthesize multi-repo four artifacts into ONE cross-repo document (unified outline + namespaced contract entities + global overview/conclusion). Requires ≥2 repos. Omit --profile to auto-detect the scene (0to1/bugfix/feature/poc/migration) from --text or the artifacts; confirm when uncertain.')
|
|
1028
|
+
.option('--workspace-root <path>', 'Multi-repo workspace root (auto-derived from --workset if omitted)')
|
|
1029
|
+
.option('--workset <name>', 'Use a saved workset: derive workspace root + each repo\'s latest change (members point at repo roots)')
|
|
1030
|
+
.option('--changes <spec>', 'Multi-repo "repo1:change1,repo2:change2" or repo-only "repo1,repo2" (auto-select latest change). Default: auto-scan workspaceRoot / all workset members')
|
|
1031
|
+
.option('--text <text>', 'Natural-language description of the cross-repo work (used for scene detection when --profile is omitted)')
|
|
1032
|
+
.option('--profile <id>', 'Synthesis profile (default: auto-detect scene from --text / artifacts; e.g. approve | 0to1 | bugfix | feature | poc | migration)')
|
|
1033
|
+
.option('--work-root <path>', 'Output directory (default: <workspaceRoot>/.specflow/document-synthesized)')
|
|
1034
|
+
.option('--signoff', 'Include the approval sign-off section')
|
|
1035
|
+
.option('--json', 'Output result as JSON')
|
|
1036
|
+
.action(async (opts) => {
|
|
1037
|
+
const { exitCode, payload } = await synthesizeDocumentCommand(opts);
|
|
1038
|
+
if (exitCode !== 0)
|
|
1039
|
+
process.exitCode = exitCode;
|
|
1040
|
+
if (opts.json)
|
|
1041
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
1042
|
+
});
|
|
1043
|
+
doc
|
|
1044
|
+
.command('approve')
|
|
1045
|
+
.description('Generate an approval document from the four artifacts (proposal/specs/design/tasks). Multi-repo with --workspace-root.')
|
|
1046
|
+
.option('--proposal <path>', 'Proposal file')
|
|
1047
|
+
.option('--specs <path>', 'Specs file')
|
|
1048
|
+
.option('--design <path>', 'Design file')
|
|
1049
|
+
.option('--tasks <path>', 'Tasks file')
|
|
1050
|
+
.option('--change <name>', 'Change name (single-repo mode)')
|
|
1051
|
+
.option('--workspace-root <path>', 'Multi-repo workspace root (scan per-repo four artifacts)')
|
|
1052
|
+
.option('--changes <spec>', 'Multi-repo "repo1:change1,repo2:change2"')
|
|
1053
|
+
.option('--work-root <path>', 'Work directory')
|
|
1054
|
+
.option('--signoff', 'Include the approval sign-off section')
|
|
1055
|
+
.option('--bundle', 'Merge each repo document.md into approval-unified.md (after all repos generated+validated)')
|
|
1056
|
+
.option('--json', 'Output result as JSON')
|
|
1057
|
+
.action(async (opts) => {
|
|
1058
|
+
const { exitCode, payload } = await approveDocumentCommand(opts);
|
|
1059
|
+
if (exitCode !== 0)
|
|
1060
|
+
process.exitCode = exitCode;
|
|
1061
|
+
if (opts.json)
|
|
1062
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
1063
|
+
});
|
|
1064
|
+
}
|