@gordon.gan/specflow 1.8.1-beta → 1.8.3-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/dist/cli/commands/document-run.d.ts +2 -1
- package/dist/cli/commands/document-run.js +240 -21
- package/dist/core/document/asset-paths.d.ts +15 -0
- package/dist/core/document/asset-paths.js +38 -0
- package/dist/core/document/chapters.js +6 -4
- package/dist/core/document/engine.js +4 -4
- package/dist/core/document/profiles.js +5 -3
- package/dist/core/document/render.d.ts +27 -0
- package/dist/core/document/render.js +84 -1
- package/dist/core/document/schemas.d.ts +8 -0
- package/dist/core/document/schemas.js +20 -0
- package/package.json +1 -1
- package/prompts/document/map/acceptance.md +1 -1
- package/prompts/document/map/api-design.md +1 -1
- package/prompts/document/map/architecture.md +1 -1
- package/prompts/document/map/closed-loop.md +1 -1
- package/prompts/document/map/compat-migration.md +1 -1
- package/prompts/document/map/config-runtime.md +1 -1
- package/prompts/document/map/core-logic.md +1 -1
- package/prompts/document/map/data-model.md +1 -1
- package/prompts/document/map/fix.md +1 -1
- package/prompts/document/map/goal.md +1 -1
- package/prompts/document/map/impact.md +1 -1
- package/prompts/document/map/implementability.md +1 -1
- package/prompts/document/map/mvp-boundary.md +1 -1
- package/prompts/document/map/non-goals.md +1 -1
- package/prompts/document/map/regression.md +1 -1
- package/prompts/document/map/reproduce.md +1 -1
- package/prompts/document/map/requirement.md +1 -1
- package/prompts/document/map/root-cause.md +1 -1
- package/prompts/document/map/signoff.md +1 -1
- package/prompts/document/map/tech-selection.md +1 -1
- package/prompts/document/map/test-strategy.md +1 -1
- package/prompts/document/map/ui-design.md +1 -1
- package/skills/specflow-techdoc-synth/SKILL.md +17 -1
|
@@ -82,7 +82,8 @@ export declare function approveDocumentCommand(options: ApproveOptions): Promise
|
|
|
82
82
|
payload: unknown;
|
|
83
83
|
}>;
|
|
84
84
|
export interface SynthesizeOptions {
|
|
85
|
-
workspaceRoot
|
|
85
|
+
workspaceRoot?: string;
|
|
86
|
+
workset?: string;
|
|
86
87
|
changes?: string;
|
|
87
88
|
profile?: string;
|
|
88
89
|
text?: string;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* for library/tests; CLI itself never fetches an API).
|
|
11
11
|
*/
|
|
12
12
|
import { promises as fs } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
13
|
+
import { join, basename, dirname, sep } from 'node:path';
|
|
14
14
|
import { loadChapterLibrary } from '../../core/document/chapters.js';
|
|
15
15
|
import { loadProfile } from '../../core/document/profiles.js';
|
|
16
16
|
import { runDocument, planSteps, validateWork } from '../../core/document/engine.js';
|
|
@@ -18,6 +18,8 @@ import { detectInputFeatures } from '../../core/document/input-features.js';
|
|
|
18
18
|
import { detectScene, confirmationPrompt } from '../../core/document/scene-detect.js';
|
|
19
19
|
import { reposPath } from '../../core/document/paths.js';
|
|
20
20
|
import { readOutline } from '../../core/document/outline.js';
|
|
21
|
+
import { readWorksetsState, defaultWorksetsStatePath } from '../../core/worksets.js';
|
|
22
|
+
import { getGlobalDataDir } from '../../core/global-config.js';
|
|
21
23
|
async function readInputFile(path, type) {
|
|
22
24
|
const content = await fs.readFile(path, 'utf-8');
|
|
23
25
|
return { type, source: path, content };
|
|
@@ -332,14 +334,32 @@ export async function detectDocumentCommand(options) {
|
|
|
332
334
|
/**
|
|
333
335
|
* Discover multi-repo four-artifact sets under a workspace root.
|
|
334
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 时按描述匹配,否则最新)
|
|
335
341
|
*/
|
|
336
|
-
async function discoverRepoChanges(workspaceRoot, spec) {
|
|
342
|
+
async function discoverRepoChanges(workspaceRoot, spec, text) {
|
|
337
343
|
const repos = [];
|
|
338
344
|
if (spec) {
|
|
339
345
|
for (const part of spec.split(',')) {
|
|
340
|
-
const
|
|
341
|
-
if (
|
|
342
|
-
|
|
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
|
+
}
|
|
343
363
|
}
|
|
344
364
|
return repos;
|
|
345
365
|
}
|
|
@@ -363,6 +383,176 @@ async function discoverRepoChanges(workspaceRoot, spec) {
|
|
|
363
383
|
}
|
|
364
384
|
return repos;
|
|
365
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
|
+
}
|
|
366
556
|
export async function approveDocumentCommand(options) {
|
|
367
557
|
try {
|
|
368
558
|
const library = await loadChapterLibrary();
|
|
@@ -538,10 +728,12 @@ function buildSynthRules(dirs) {
|
|
|
538
728
|
/**
|
|
539
729
|
* 解析合成用 profile:显式 --profile 优先;省略时从 --text(或四件套拼接内容)做场景识别。
|
|
540
730
|
* - 识别置信度达标 → 直接采用识别结果。
|
|
541
|
-
* - 识别不出且用户给了 --text
|
|
731
|
+
* - 识别不出且用户给了 --text:
|
|
732
|
+
* - workset 模式(weakDefault 传 true)→ 取分数最高的候选 profile 兜底(如 feature),不打断流程;
|
|
733
|
+
* - 否则 → 返回 needsConfirmation(与 document run 行为一致,向用户确认)。
|
|
542
734
|
* - 识别不出且无 --text → 回退 approve(原 synthesize 默认,保持向后兼容,不打断流程)。
|
|
543
735
|
*/
|
|
544
|
-
function resolveSynthProfile(options, inputs) {
|
|
736
|
+
function resolveSynthProfile(options, inputs, weakDefault = false) {
|
|
545
737
|
if (options.profile)
|
|
546
738
|
return { profileId: options.profile };
|
|
547
739
|
const detectText = (options.text ?? inputs.map((i) => i.content).join('\n')).trim();
|
|
@@ -550,6 +742,12 @@ function resolveSynthProfile(options, inputs) {
|
|
|
550
742
|
return { profileId: det.profile, detectedText: detectText };
|
|
551
743
|
}
|
|
552
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
|
+
}
|
|
553
751
|
const message = confirmationPrompt(det, detectText) +
|
|
554
752
|
'\n\n确认后重新运行,例如:\n specflow techdoc synthesize --workspace-root <root> --profile <profile> ...';
|
|
555
753
|
return {
|
|
@@ -563,17 +761,27 @@ function resolveSynthProfile(options, inputs) {
|
|
|
563
761
|
}
|
|
564
762
|
export async function synthesizeDocumentCommand(options) {
|
|
565
763
|
try {
|
|
566
|
-
// workspaceRoot
|
|
567
|
-
|
|
568
|
-
|
|
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)。';
|
|
569
778
|
if (options.json)
|
|
570
779
|
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
571
780
|
console.error(`Error: ${msg}`);
|
|
572
781
|
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
573
782
|
}
|
|
574
|
-
const dirs = await discoverRepoChanges(options.workspaceRoot, options.changes ?? '');
|
|
575
783
|
if (dirs.length === 0) {
|
|
576
|
-
const msg = 'No multi-repo four-artifact sets found. Use --workspace-root with --changes "repo1:ch1,repo2:ch2" or
|
|
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>.';
|
|
577
785
|
if (options.json)
|
|
578
786
|
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
579
787
|
console.error(`Error: ${msg}`);
|
|
@@ -601,8 +809,9 @@ export async function synthesizeDocumentCommand(options) {
|
|
|
601
809
|
console.error(`Error: ${msg}`);
|
|
602
810
|
return { exitCode: 1, payload: { ok: false, error: msg } };
|
|
603
811
|
}
|
|
604
|
-
// 场景识别:显式 --profile 优先;省略时从 --text /
|
|
605
|
-
|
|
812
|
+
// 场景识别:显式 --profile 优先;省略时从 --text / 四件套内容识别。
|
|
813
|
+
// workset 模式描述模糊 → weakDefault 兜底(取分数最高候选,不打断);workspace-root 模式不确定 → 向用户确认。
|
|
814
|
+
const resolved = resolveSynthProfile(options, inputs, !!options.workset);
|
|
606
815
|
if (resolved.needsConfirmation) {
|
|
607
816
|
const payload = {
|
|
608
817
|
ok: false,
|
|
@@ -620,10 +829,12 @@ export async function synthesizeDocumentCommand(options) {
|
|
|
620
829
|
? { profile: profileId, source: 'explicit' }
|
|
621
830
|
: resolved.fallback
|
|
622
831
|
? { profile: profileId, source: 'fallback' }
|
|
623
|
-
:
|
|
832
|
+
: resolved.weakDefault
|
|
833
|
+
? { profile: profileId, source: 'weak-default' }
|
|
834
|
+
: { profile: profileId, source: 'detected' };
|
|
624
835
|
const library = await loadChapterLibrary();
|
|
625
836
|
const profile = await loadProfile(profileId);
|
|
626
|
-
const workRoot = options.workRoot ?? join(
|
|
837
|
+
const workRoot = options.workRoot ?? join(workspaceRoot, '.specflow', 'document-synthesized');
|
|
627
838
|
await fs.mkdir(workRoot, { recursive: true });
|
|
628
839
|
// Multi-repo synthesize marker: validateWork reads this to enforce the per-repo section gate.
|
|
629
840
|
// 只有 synthesize 写 repos.json;approve --bundle / document run 不写 → 门禁不触发。
|
|
@@ -643,16 +854,23 @@ export async function synthesizeDocumentCommand(options) {
|
|
|
643
854
|
workRoot,
|
|
644
855
|
});
|
|
645
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
|
+
: '';
|
|
646
860
|
const sceneLine = scene.source === 'detected'
|
|
647
861
|
? `(场景识别:${scene.profile})`
|
|
648
|
-
: scene.source === '
|
|
649
|
-
?
|
|
650
|
-
: ''
|
|
862
|
+
: scene.source === 'weak-default'
|
|
863
|
+
? `(场景识别较弱,默认采用 ${scene.profile})`
|
|
864
|
+
: scene.source === 'fallback'
|
|
865
|
+
? '(场景未识别,回退 approve)'
|
|
866
|
+
: '';
|
|
651
867
|
const directive = [
|
|
652
868
|
`# 跨仓合成文档(${profile.id} · ${dirs.length} 仓${sceneLine})`,
|
|
653
869
|
'',
|
|
654
870
|
`请作为 Agent 用当前 IDE 模型自动完成以下步骤(勿中途停下等用户分步),产物写入 workRoot: ${workRoot}`,
|
|
655
871
|
'',
|
|
872
|
+
`## 参与仓与 change:${repoList}${selectionNote}`,
|
|
873
|
+
'',
|
|
656
874
|
`## 输入来源(多仓统一输入,每段已标记「来源仓」,段 id 携带仓前缀)`,
|
|
657
875
|
...inputs.map((i) => `- \`${i.source}\`(${i.type} · 来自 ${i.content.split('\n')[0] ?? ''})`),
|
|
658
876
|
'',
|
|
@@ -807,8 +1025,9 @@ export function registerDocumentRunCommand(program) {
|
|
|
807
1025
|
doc
|
|
808
1026
|
.command('synthesize')
|
|
809
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.')
|
|
810
|
-
.option('--workspace-root <path>', 'Multi-repo workspace root (
|
|
811
|
-
.option('--
|
|
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')
|
|
812
1031
|
.option('--text <text>', 'Natural-language description of the cross-repo work (used for scene detection when --profile is omitted)')
|
|
813
1032
|
.option('--profile <id>', 'Synthesis profile (default: auto-detect scene from --text / artifacts; e.g. approve | 0to1 | bugfix | feature | poc | migration)')
|
|
814
1033
|
.option('--work-root <path>', 'Output directory (default: <workspaceRoot>/.specflow/document-synthesized)')
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document Engine — template asset path resolution.
|
|
3
|
+
*
|
|
4
|
+
* Templates (templates/document/chapters, templates/document/profiles) are loaded from two
|
|
5
|
+
* places, in priority order:
|
|
6
|
+
* 1. `<project-cwd>/templates` — user-overridable templates (e.g. a repo ships its own).
|
|
7
|
+
* 2. The specflow package's own `templates/` dir — so the CLI works from ANY directory
|
|
8
|
+
* (multi-repo workspaces, repos initialized elsewhere) instead of failing when cwd
|
|
9
|
+
* has no templates. This fixes "synthesize 因模板路径失败" when running in a workspace
|
|
10
|
+
* that is not the specflow checkout.
|
|
11
|
+
*/
|
|
12
|
+
/** 候选模板根目录(含用户 cwd 覆盖与包内 fallback)。 */
|
|
13
|
+
export declare function templateRoots(projectDir?: string): string[];
|
|
14
|
+
/** 在模板根目录候选里找第一个存在的目录;都不存在则返回包内默认(让上层报可读错误)。 */
|
|
15
|
+
export declare function resolveTemplateDir(subpath: string, projectDir?: string): Promise<string>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document Engine — template asset path resolution.
|
|
3
|
+
*
|
|
4
|
+
* Templates (templates/document/chapters, templates/document/profiles) are loaded from two
|
|
5
|
+
* places, in priority order:
|
|
6
|
+
* 1. `<project-cwd>/templates` — user-overridable templates (e.g. a repo ships its own).
|
|
7
|
+
* 2. The specflow package's own `templates/` dir — so the CLI works from ANY directory
|
|
8
|
+
* (multi-repo workspaces, repos initialized elsewhere) instead of failing when cwd
|
|
9
|
+
* has no templates. This fixes "synthesize 因模板路径失败" when running in a workspace
|
|
10
|
+
* that is not the specflow checkout.
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
/** specflow 包根:从当前模块(dist/core/document/*.js)上溯到包根(templates 所在层)。 */
|
|
16
|
+
function packageRoot() {
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/dist/core/document
|
|
18
|
+
return dirname(dirname(dirname(here))); // <pkg>/dist/core → <pkg>/dist → <pkg>
|
|
19
|
+
}
|
|
20
|
+
/** 候选模板根目录(含用户 cwd 覆盖与包内 fallback)。 */
|
|
21
|
+
export function templateRoots(projectDir = process.cwd()) {
|
|
22
|
+
return [join(projectDir, 'templates'), join(packageRoot(), 'templates')];
|
|
23
|
+
}
|
|
24
|
+
/** 在模板根目录候选里找第一个存在的目录;都不存在则返回包内默认(让上层报可读错误)。 */
|
|
25
|
+
export async function resolveTemplateDir(subpath, projectDir) {
|
|
26
|
+
for (const root of templateRoots(projectDir)) {
|
|
27
|
+
const candidate = join(root, subpath);
|
|
28
|
+
try {
|
|
29
|
+
const st = await fs.stat(candidate);
|
|
30
|
+
if (st.isDirectory())
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// not present → try next
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return join(packageRoot(), 'templates', subpath);
|
|
38
|
+
}
|
|
@@ -6,9 +6,11 @@ import { promises as fs } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import yaml from 'js-yaml';
|
|
8
8
|
import { parseChapterComponent } from './schemas.js';
|
|
9
|
-
|
|
10
|
-
export async function loadChapterLibrary(dir
|
|
11
|
-
|
|
9
|
+
import { resolveTemplateDir } from './asset-paths.js';
|
|
10
|
+
export async function loadChapterLibrary(dir) {
|
|
11
|
+
// 默认从「用户 cwd/templates 优先、包内 templates fallback」解析(多仓工作区也能跑)。
|
|
12
|
+
const resolvedDir = dir ?? (await resolveTemplateDir(join('document', 'chapters')));
|
|
13
|
+
const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
|
|
12
14
|
const lib = new Map();
|
|
13
15
|
const seen = new Set();
|
|
14
16
|
for (const entry of entries) {
|
|
@@ -19,7 +21,7 @@ export async function loadChapterLibrary(dir = DEFAULT_CHAPTERS_DIR) {
|
|
|
19
21
|
continue;
|
|
20
22
|
let raw;
|
|
21
23
|
try {
|
|
22
|
-
raw = yaml.load(await fs.readFile(join(
|
|
24
|
+
raw = yaml.load(await fs.readFile(join(resolvedDir, file), 'utf-8'));
|
|
23
25
|
}
|
|
24
26
|
catch (e) {
|
|
25
27
|
throw new Error(`Failed to parse chapter file ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -16,7 +16,7 @@ import { buildReviewPacket, reviewWithAgent, checkReviewResult, writeReviewResul
|
|
|
16
16
|
import { renderDocument, renderHtml } from './render.js';
|
|
17
17
|
import { runGates } from './gates.js';
|
|
18
18
|
import { resolvePaths, reposPath, chapterPath } from './paths.js';
|
|
19
|
-
import {
|
|
19
|
+
import { parseEntitiesLenient } from './schemas.js';
|
|
20
20
|
import { lintNarrative, checkRepoSectionCoverage } from './lint.js';
|
|
21
21
|
import { checkStructuredCoverage } from './coverage.js';
|
|
22
22
|
import { resolveProjectConventionPaths } from '../project-conventions.js';
|
|
@@ -409,7 +409,7 @@ function extractChapterEntities(content) {
|
|
|
409
409
|
let m;
|
|
410
410
|
while ((m = jsonBlockRe.exec(content)) !== null) {
|
|
411
411
|
try {
|
|
412
|
-
const parsed =
|
|
412
|
+
const parsed = parseEntitiesLenient(JSON.parse(m[1]));
|
|
413
413
|
mergeEntities(out, parsed);
|
|
414
414
|
}
|
|
415
415
|
catch {
|
|
@@ -642,7 +642,7 @@ export async function validateWork(options) {
|
|
|
642
642
|
let jm;
|
|
643
643
|
while ((jm = jsonBlockRe.exec(content)) !== null) {
|
|
644
644
|
try {
|
|
645
|
-
const parsed =
|
|
645
|
+
const parsed = parseEntitiesLenient(JSON.parse(jm[1]));
|
|
646
646
|
chapterEntities.interfaces.push(...parsed.interfaces);
|
|
647
647
|
chapterEntities.tables.push(...parsed.tables);
|
|
648
648
|
chapterEntities.decisions.push(...parsed.decisions);
|
|
@@ -660,7 +660,7 @@ export async function validateWork(options) {
|
|
|
660
660
|
// entities.json.
|
|
661
661
|
try {
|
|
662
662
|
const raw = await fs.readFile(paths.entities, 'utf-8');
|
|
663
|
-
const parsed =
|
|
663
|
+
const parsed = parseEntitiesLenient(JSON.parse(raw));
|
|
664
664
|
allEntities.interfaces = parsed.interfaces;
|
|
665
665
|
allEntities.tables = parsed.tables;
|
|
666
666
|
allEntities.decisions = parsed.decisions;
|
|
@@ -9,14 +9,16 @@ import yaml from 'js-yaml';
|
|
|
9
9
|
import { parseScenarioProfile } from './schemas.js';
|
|
10
10
|
import { validateProfile, hasErrors } from './profile-validator.js';
|
|
11
11
|
import { evaluateWhen } from './input-features.js';
|
|
12
|
-
|
|
12
|
+
import { resolveTemplateDir } from './asset-paths.js';
|
|
13
13
|
/** profile id 白名单(P2):拒绝路径穿越(../、绝对路径)。 */
|
|
14
14
|
const PROFILE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
15
|
-
export async function loadProfile(profileId, dir
|
|
15
|
+
export async function loadProfile(profileId, dir) {
|
|
16
16
|
if (!PROFILE_ID_RE.test(profileId)) {
|
|
17
17
|
throw new Error(`Invalid profile id "${profileId}": must match ${PROFILE_ID_RE}`);
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// 默认从「用户 cwd/templates 优先、包内 templates fallback」解析(多仓工作区也能跑)。
|
|
20
|
+
const resolvedDir = dir ?? (await resolveTemplateDir(join('document', 'profiles')));
|
|
21
|
+
const raw = yaml.load(await fs.readFile(join(resolvedDir, `${profileId}.yaml`), 'utf-8'));
|
|
20
22
|
return parseScenarioProfile(raw);
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
@@ -37,6 +37,33 @@ export interface RenderDocumentInput {
|
|
|
37
37
|
* stays in `chapters/<id>.md` and `review-result.json`; the deliverable document must not show
|
|
38
38
|
* internal fix notes. */
|
|
39
39
|
export declare function stripReviewNotes(text: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* 从章节叙述中剥离内嵌的**契约实体 JSON 块**(```json {"entities": {...}} ```)。
|
|
42
|
+
* 这些块是 Agent 写给引擎提取的机器中间格式(map 提示词要求 kind=entity|mixed 要点产出),
|
|
43
|
+
* 引擎已把其中实体提取进 entities.json,并在文档末尾「契约实体」章节渲染为人类可读的表格。
|
|
44
|
+
* 最终 document.md 不应再出现原始 JSON——否则读者看到看不懂的 `decisions`/`interfaces` 结构。
|
|
45
|
+
*
|
|
46
|
+
* 判定:JSON 可解析且按 Entities schema 解析成功(含 interfaces/tables/decisions 任一非空)
|
|
47
|
+
* 才剥离;无法解析的块(叙述中的示例 JSON)原样保留,避免误删正文。
|
|
48
|
+
*/
|
|
49
|
+
export declare function stripEntityJsonBlocks(text: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* 剥离章节叙述首行的**冗余 H1 标题**:叙述以 `# <章节标题>` 开头(Agent 生成正文时习惯性地
|
|
52
|
+
* 重复了大纲章节标题),而 renderDocument 已为每章输出 `## <title>`——二者叠加造成标题重复。
|
|
53
|
+
* 仅当首行非空是 H1 且其内容与章节标题 trim 后一致时才剥离,避免误删正文中的真实内容。
|
|
54
|
+
*/
|
|
55
|
+
export declare function stripLeadingTitle(text: string, chapterTitle: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* 章节叙述标题整体降级 n 级(默认 1)。原因:renderDocument 已为每章输出 `## <章节标题>`(H2),
|
|
58
|
+
* 而 Agent 生成的叙述内小节标题习惯用 `##`(H2)、分仓小节用 `###`(H3)——与章节头同级,
|
|
59
|
+
* 造成最终文档目录层级乱(`## 需求` 与 `## 跨仓总目标` 平级)。渲染时降级:`##`→`###`、`###`→`####`…,
|
|
60
|
+
* 使章节头 H2 → 叙述小节 H3 → 分仓 H4,层级正确。
|
|
61
|
+
*
|
|
62
|
+
* 只处理行首的 markdown 标题(`#{1,6}\s`),并跳过围栏代码块(mermaid/``` 内部行不动),
|
|
63
|
+
* 避免把代码内容里的 `#` 误判成标题。H6 封顶不再升。质量门禁 `### <repo>` 检查的是原始章节
|
|
64
|
+
* 文件(chapters/*.md),不在此处降级影响范围——仅最终渲染的 document.md 生效。
|
|
65
|
+
*/
|
|
66
|
+
export declare function demoteHeadings(text: string, levels?: number): string;
|
|
40
67
|
export declare function renderDocument(input: RenderDocumentInput): string;
|
|
41
68
|
/** 附录 B · 去AI味自检(frontend-dev-guide §十一 → README §1.6 五维表)。 */
|
|
42
69
|
export declare function renderAppendixAntiAI(content?: string): string;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Optional HTML render reuses `marked`.
|
|
7
7
|
*/
|
|
8
8
|
import { marked } from 'marked';
|
|
9
|
+
import { parseEntities } from './schemas.js';
|
|
9
10
|
// ============= Escaping helpers(P1-5/6:HTML 与 markdown 注入面) =============
|
|
10
11
|
/** HTML 转义:用于 <title>/<h1> 插值(防存储型 XSS)。 */
|
|
11
12
|
export function escapeHtml(s) {
|
|
@@ -97,12 +98,94 @@ export function renderEntities(entities) {
|
|
|
97
98
|
export function stripReviewNotes(text) {
|
|
98
99
|
return text.replace(/<!--\s*review-fix[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* 从章节叙述中剥离内嵌的**契约实体 JSON 块**(```json {"entities": {...}} ```)。
|
|
103
|
+
* 这些块是 Agent 写给引擎提取的机器中间格式(map 提示词要求 kind=entity|mixed 要点产出),
|
|
104
|
+
* 引擎已把其中实体提取进 entities.json,并在文档末尾「契约实体」章节渲染为人类可读的表格。
|
|
105
|
+
* 最终 document.md 不应再出现原始 JSON——否则读者看到看不懂的 `decisions`/`interfaces` 结构。
|
|
106
|
+
*
|
|
107
|
+
* 判定:JSON 可解析且按 Entities schema 解析成功(含 interfaces/tables/decisions 任一非空)
|
|
108
|
+
* 才剥离;无法解析的块(叙述中的示例 JSON)原样保留,避免误删正文。
|
|
109
|
+
*/
|
|
110
|
+
export function stripEntityJsonBlocks(text) {
|
|
111
|
+
const jsonBlockRe = /```json\s*([\s\S]*?)```/g;
|
|
112
|
+
const parts = [];
|
|
113
|
+
let last = 0;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = jsonBlockRe.exec(text)) !== null) {
|
|
116
|
+
parts.push(text.slice(last, m.index));
|
|
117
|
+
let isEntityBlock = false;
|
|
118
|
+
try {
|
|
119
|
+
const parsed = parseEntities(JSON.parse(m[1]));
|
|
120
|
+
isEntityBlock = (parsed.interfaces?.length ?? 0) + (parsed.tables?.length ?? 0) + (parsed.decisions?.length ?? 0) > 0;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
isEntityBlock = false;
|
|
124
|
+
}
|
|
125
|
+
if (!isEntityBlock)
|
|
126
|
+
parts.push(m[0]);
|
|
127
|
+
last = m.index + m[0].length;
|
|
128
|
+
}
|
|
129
|
+
parts.push(text.slice(last));
|
|
130
|
+
return parts.join('').replace(/\n{3,}/g, '\n\n').trim();
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 剥离章节叙述首行的**冗余 H1 标题**:叙述以 `# <章节标题>` 开头(Agent 生成正文时习惯性地
|
|
134
|
+
* 重复了大纲章节标题),而 renderDocument 已为每章输出 `## <title>`——二者叠加造成标题重复。
|
|
135
|
+
* 仅当首行非空是 H1 且其内容与章节标题 trim 后一致时才剥离,避免误删正文中的真实内容。
|
|
136
|
+
*/
|
|
137
|
+
export function stripLeadingTitle(text, chapterTitle) {
|
|
138
|
+
const trimmed = text.trim();
|
|
139
|
+
const firstLine = trimmed.split('\n', 1)[0] ?? '';
|
|
140
|
+
const m = /^#\s+(.*)$/.exec(firstLine.trim());
|
|
141
|
+
if (!m)
|
|
142
|
+
return text;
|
|
143
|
+
if (m[1].trim() !== chapterTitle.trim())
|
|
144
|
+
return text;
|
|
145
|
+
// 去掉首行后,剩余部分(去掉紧邻的空行)即为叙述正文。
|
|
146
|
+
const rest = trimmed.slice(firstLine.length).replace(/^\n+/, '');
|
|
147
|
+
return rest;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 章节叙述标题整体降级 n 级(默认 1)。原因:renderDocument 已为每章输出 `## <章节标题>`(H2),
|
|
151
|
+
* 而 Agent 生成的叙述内小节标题习惯用 `##`(H2)、分仓小节用 `###`(H3)——与章节头同级,
|
|
152
|
+
* 造成最终文档目录层级乱(`## 需求` 与 `## 跨仓总目标` 平级)。渲染时降级:`##`→`###`、`###`→`####`…,
|
|
153
|
+
* 使章节头 H2 → 叙述小节 H3 → 分仓 H4,层级正确。
|
|
154
|
+
*
|
|
155
|
+
* 只处理行首的 markdown 标题(`#{1,6}\s`),并跳过围栏代码块(mermaid/``` 内部行不动),
|
|
156
|
+
* 避免把代码内容里的 `#` 误判成标题。H6 封顶不再升。质量门禁 `### <repo>` 检查的是原始章节
|
|
157
|
+
* 文件(chapters/*.md),不在此处降级影响范围——仅最终渲染的 document.md 生效。
|
|
158
|
+
*/
|
|
159
|
+
export function demoteHeadings(text, levels = 1) {
|
|
160
|
+
if (levels <= 0)
|
|
161
|
+
return text;
|
|
162
|
+
const lines = text.split('\n');
|
|
163
|
+
let inFence = false;
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
const trimmed = line.trimStart();
|
|
167
|
+
if (trimmed.startsWith('```'))
|
|
168
|
+
inFence = !inFence;
|
|
169
|
+
if (!inFence) {
|
|
170
|
+
const m = /^(#{1,6})(\s.*)$/.exec(line);
|
|
171
|
+
if (m) {
|
|
172
|
+
const hashes = Math.min(m[1].length + levels, 6);
|
|
173
|
+
out.push('#'.repeat(hashes) + m[2]);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
out.push(line);
|
|
178
|
+
}
|
|
179
|
+
return out.join('\n');
|
|
180
|
+
}
|
|
100
181
|
export function renderDocument(input) {
|
|
101
182
|
const { outline, entities, narratives, profile, appendixAntiAI } = input;
|
|
102
183
|
const body = [`# 方案文档:${escapeInline(outline.profile)}`, ''];
|
|
103
184
|
for (const ch of outline.chapters) {
|
|
104
185
|
body.push(`## ${escapeInline(ch.title)}`, '');
|
|
105
|
-
|
|
186
|
+
// 依次净化:剥内嵌契约 JSON 块(机器中间格式)→ 剥与章节标题重复的首行 H1 → 剥引擎修复注记,
|
|
187
|
+
// 再把叙述内标题降级一级(章节头已占 H2,叙述小节 H2→H3、分仓 H3→H4),保证目录层级正确。
|
|
188
|
+
const narrative = demoteHeadings(stripLeadingTitle(stripEntityJsonBlocks(stripReviewNotes(narratives.get(ch.id) ?? '')), ch.title));
|
|
106
189
|
if (narrative.trim())
|
|
107
190
|
body.push(narrative.trim(), '');
|
|
108
191
|
}
|
|
@@ -1098,6 +1098,14 @@ export declare function parseInterfaceEntity(raw: unknown): InterfaceEntity;
|
|
|
1098
1098
|
export declare function parseTableEntity(raw: unknown): TableEntity;
|
|
1099
1099
|
export declare function parseDecisionEntity(raw: unknown): DecisionEntity;
|
|
1100
1100
|
export declare function parseEntities(raw: unknown): Entities;
|
|
1101
|
+
/**
|
|
1102
|
+
* 兼容两种章节 JSON 块格式的实体解析:
|
|
1103
|
+
* - 未包装:`{"interfaces": [...], "tables": [...], "decisions": [...]}`(引擎推荐,validate 期望)
|
|
1104
|
+
* - 包装:`{"entities": {"interfaces": [...]}}`(部分 map 提示词曾要求,Agent 可能照此写)
|
|
1105
|
+
* zod 默认 strip 未知 key,若顶层只有 `entities` 会被剥成空实体——这里显式解包后再解析,
|
|
1106
|
+
* 避免「提示词包装格式 → 实体被剥空 → validate 报缺失 → Agent 反复返工」。
|
|
1107
|
+
*/
|
|
1108
|
+
export declare function parseEntitiesLenient(raw: unknown): Entities;
|
|
1101
1109
|
export declare function parseOutline(raw: unknown): Outline;
|
|
1102
1110
|
export declare function parseChapterComponent(raw: unknown): ChapterComponent;
|
|
1103
1111
|
export declare function parseScenarioProfile(raw: unknown): ScenarioProfile;
|
|
@@ -160,6 +160,26 @@ export function parseDecisionEntity(raw) {
|
|
|
160
160
|
export function parseEntities(raw) {
|
|
161
161
|
return EntitiesSchema.parse(raw);
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* 兼容两种章节 JSON 块格式的实体解析:
|
|
165
|
+
* - 未包装:`{"interfaces": [...], "tables": [...], "decisions": [...]}`(引擎推荐,validate 期望)
|
|
166
|
+
* - 包装:`{"entities": {"interfaces": [...]}}`(部分 map 提示词曾要求,Agent 可能照此写)
|
|
167
|
+
* zod 默认 strip 未知 key,若顶层只有 `entities` 会被剥成空实体——这里显式解包后再解析,
|
|
168
|
+
* 避免「提示词包装格式 → 实体被剥空 → validate 报缺失 → Agent 反复返工」。
|
|
169
|
+
*/
|
|
170
|
+
export function parseEntitiesLenient(raw) {
|
|
171
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
172
|
+
const obj = raw;
|
|
173
|
+
if (obj.entities && typeof obj.entities === 'object' && !Array.isArray(obj.entities)) {
|
|
174
|
+
const inner = obj.entities;
|
|
175
|
+
// 仅当解包后含实体字段才解包;否则保持原样(可能是非实体叙述块)。
|
|
176
|
+
if ('interfaces' in inner || 'tables' in inner || 'decisions' in inner) {
|
|
177
|
+
return parseEntities(inner);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return parseEntities(raw);
|
|
182
|
+
}
|
|
163
183
|
export function parseOutline(raw) {
|
|
164
184
|
return OutlineSchema.parse(raw);
|
|
165
185
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:acceptance
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:api-design(接口设计 · 契约 + 前端对接)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:architecture(架构设计 · C4 分层 + ADR)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:closed-loop
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:compat-migration(兼容性与迁移 · 新旧 API 对照)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:config-runtime
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:core-logic
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:data-model(数据结构/数据模型变更)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:fix
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:goal
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:impact
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:implementability
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:mvp-boundary
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:non-goals
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:regression
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:reproduce
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:requirement
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:root-cause
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:signoff
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:tech-selection(技术方案评估 / 技术选型)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:test-strategy(测试策略 · 前端金字塔)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 章节填充:ui-design(前端/UI 设计 · 扩展状态机/路由守卫/埋点/浏览器兼容)
|
|
2
2
|
|
|
3
3
|
你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
|
|
4
|
-
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"
|
|
4
|
+
- kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"interfaces": [...], "tables": [...], "decisions": [...]} ```)
|
|
5
5
|
- kind=narrative|mixed 的要点 → 产出叙述 Markdown
|
|
6
6
|
- 遵循 prompts/shared/artifact-language.md 的中文叙述规范(简要;怎么做用 1. 2. 3.)
|
|
7
7
|
- 禁止 stub(TODO/待补充/此处省略);禁止含糊词
|
|
@@ -10,13 +10,29 @@ description: "One-command multi-repo technical solution document synthesis — m
|
|
|
10
10
|
## Invocation(一键入口)
|
|
11
11
|
|
|
12
12
|
```text
|
|
13
|
-
/specflow:techdoc-synth run [--workspace-root <root>] [--changes "repo1:ch1,repo2:ch2"] [--text "..."] [--profile <id>] [--work-root <path>] [--signoff]
|
|
13
|
+
/specflow:techdoc-synth run [--workspace-root <root> | --workset <name>] [--changes "repo1:ch1,repo2:ch2" | "repo1,repo2"] [--text "..."] [--profile <id>] [--work-root <path>] [--signoff]
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Cursor: `specflow:techdoc-synth run ...`; Codex: `$specflow-techdoc-synth run ...`。
|
|
17
17
|
|
|
18
18
|
**收到 `run` 命令后,你必须自动完成全流程,不得中途停下要求分步确认**(除非产物校验失败需要修正)。
|
|
19
19
|
|
|
20
|
+
## 低成本调用(推荐,三种写法)
|
|
21
|
+
|
|
22
|
+
命令长是常见痛点,`synthesize` 已内置三档简化:
|
|
23
|
+
|
|
24
|
+
| 写法 | 命令 | 适用 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| **A. `--workset`(最简,推荐)** | `/specflow:techdoc-synth run --workset talos-platform` | 已用 `specflow workset create` 存过三仓路径(成员指向各仓根目录),一次创建、后续零成本 |
|
|
27
|
+
| **B. 只写仓名** | `/specflow:techdoc-synth run --workspace-root <root> --changes "talos,talos-web,talos-worker"` | 仓名短、change 名长时;自动选每仓最新 change |
|
|
28
|
+
| **C. 省略 `--profile`** | 上两式均可省略 `--profile`,从 `--text`(优先)或四件套自动识别场景 | 场景关键词明确时;识别不出且有 `--text` → 向你确认 |
|
|
29
|
+
|
|
30
|
+
- **workset 语义**:`--workset <name>` 自动推导 workspace-root(成员公共父目录)+ 每仓**最新非 archive change**;`--changes` 可覆盖(支持成员 label 或仓目录名,如 `--changes "api,web:ch2"`)。
|
|
31
|
+
- **`--text` 自动识别涉及哪些功能**:省略 `--changes` 且提供 `--text` 时,按描述**自动匹配每仓最相关的 change**(对 proposal/design 摘要做 token 频率打分,change 名命中加分,并列取较新者;无关联才回退「最新」)。例:`--text "案例场景"` 自动选中三仓的 scenario-job-compile / web-scenario-cases / scenario-execution。
|
|
32
|
+
- **描述模糊不打断(workset 模式)**:`--text` 识别场景不确定时,workset 模式默认取分数最高候选(如 feature)继续,directive 标注「场景识别较弱,默认采用 …」;`--workspace-root` 模式保持确认。
|
|
33
|
+
- **零参数全自动**:`--workset <name>` + `--text "..."` 即可——workspace-root、各仓 change、场景 profile 全部自动解析。
|
|
34
|
+
- **创建 workset**:`specflow workset create <name> --member "api:/path/repo1" --member "web:/path/repo2"`(或编辑 `~/.local/share/specflow/worksets/worksets.yaml`)。
|
|
35
|
+
|
|
20
36
|
## 场景自动识别(省略 --profile 时)
|
|
21
37
|
|
|
22
38
|
与 `techdoc` 相同,`techdoc-synth` 支持场景识别:
|