@gordon.gan/specflow 1.8.1-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.
Files changed (35) hide show
  1. package/dist/cli/commands/document-run.d.ts +2 -1
  2. package/dist/cli/commands/document-run.js +240 -21
  3. package/dist/core/document/asset-paths.d.ts +15 -0
  4. package/dist/core/document/asset-paths.js +38 -0
  5. package/dist/core/document/chapters.js +6 -4
  6. package/dist/core/document/engine.js +4 -4
  7. package/dist/core/document/profiles.js +5 -3
  8. package/dist/core/document/render.d.ts +16 -0
  9. package/dist/core/document/render.js +51 -1
  10. package/dist/core/document/schemas.d.ts +8 -0
  11. package/dist/core/document/schemas.js +20 -0
  12. package/package.json +1 -1
  13. package/prompts/document/map/acceptance.md +1 -1
  14. package/prompts/document/map/api-design.md +1 -1
  15. package/prompts/document/map/architecture.md +1 -1
  16. package/prompts/document/map/closed-loop.md +1 -1
  17. package/prompts/document/map/compat-migration.md +1 -1
  18. package/prompts/document/map/config-runtime.md +1 -1
  19. package/prompts/document/map/core-logic.md +1 -1
  20. package/prompts/document/map/data-model.md +1 -1
  21. package/prompts/document/map/fix.md +1 -1
  22. package/prompts/document/map/goal.md +1 -1
  23. package/prompts/document/map/impact.md +1 -1
  24. package/prompts/document/map/implementability.md +1 -1
  25. package/prompts/document/map/mvp-boundary.md +1 -1
  26. package/prompts/document/map/non-goals.md +1 -1
  27. package/prompts/document/map/regression.md +1 -1
  28. package/prompts/document/map/reproduce.md +1 -1
  29. package/prompts/document/map/requirement.md +1 -1
  30. package/prompts/document/map/root-cause.md +1 -1
  31. package/prompts/document/map/signoff.md +1 -1
  32. package/prompts/document/map/tech-selection.md +1 -1
  33. package/prompts/document/map/test-strategy.md +1 -1
  34. package/prompts/document/map/ui-design.md +1 -1
  35. 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: string;
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 [repo, change] = part.trim().split(':');
341
- if (repo && change)
342
- repos.push({ repo, change, root: join(workspaceRoot, repo) });
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 → 返回 needsConfirmation(与 document run 行为一致,向用户确认)。
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 必填(P2):缺省时 fs.readdir(undefined) 会抛裸 TypeError。
567
- if (!options.workspaceRoot) {
568
- const msg = 'synthesize 需要 --workspace-root <path>(多仓工作区根目录,必填)。';
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 auto-scan.';
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 / 四件套内容识别,不确定则向用户确认(同 document run)。
605
- const resolved = resolveSynthProfile(options, inputs);
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
- : { profile: profileId, source: 'detected' };
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(options.workspaceRoot, '.specflow', 'document-synthesized');
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 === 'fallback'
649
- ? '(场景未识别,回退 approve)'
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 (required)')
811
- .option('--changes <spec>', 'Multi-repo "repo1:change1,repo2:change2" (default: auto-scan workspaceRoot)')
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
- const DEFAULT_CHAPTERS_DIR = join(process.cwd(), 'templates', 'document', 'chapters');
10
- export async function loadChapterLibrary(dir = DEFAULT_CHAPTERS_DIR) {
11
- const entries = await fs.readdir(dir, { withFileTypes: true });
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(dir, file), 'utf-8'));
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 { parseEntities } from './schemas.js';
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 = parseEntities(JSON.parse(m[1]));
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 = parseEntities(JSON.parse(jm[1]));
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 = parseEntities(JSON.parse(raw));
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
- const DEFAULT_PROFILES_DIR = join(process.cwd(), 'templates', 'document', 'profiles');
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 = DEFAULT_PROFILES_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
- const raw = yaml.load(await fs.readFile(join(dir, `${profileId}.yaml`), 'utf-8'));
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,22 @@ 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;
40
56
  export declare function renderDocument(input: RenderDocumentInput): string;
41
57
  /** 附录 B · 去AI味自检(frontend-dev-guide §十一 → README §1.6 五维表)。 */
42
58
  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,61 @@ 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
+ }
100
149
  export function renderDocument(input) {
101
150
  const { outline, entities, narratives, profile, appendixAntiAI } = input;
102
151
  const body = [`# 方案文档:${escapeInline(outline.profile)}`, ''];
103
152
  for (const ch of outline.chapters) {
104
153
  body.push(`## ${escapeInline(ch.title)}`, '');
105
- const narrative = stripReviewNotes(narratives.get(ch.id) ?? '');
154
+ // 依次净化:剥内嵌契约 JSON 块(机器中间格式)→ 剥与章节标题重复的首行 H1 → 剥引擎修复注记。
155
+ const narrative = stripLeadingTitle(stripEntityJsonBlocks(stripReviewNotes(narratives.get(ch.id) ?? '')), ch.title);
106
156
  if (narrative.trim())
107
157
  body.push(narrative.trim(), '');
108
158
  }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gordon.gan/specflow",
3
- "version": "1.8.1-beta",
3
+ "version": "1.8.2-beta",
4
4
  "type": "module",
5
5
  "description": "SpecFlow — unified spec-driven development: OpenSpec planning + Superpowers execution in one CLI and cross-IDE workflow",
6
6
  "keywords": [
@@ -1,7 +1,7 @@
1
1
  # 章节填充:acceptance
2
2
 
3
3
  你是方案文档撰写员。按大纲要点"填空题"式展开本章,逐要点填充,不自由发挥。
4
- - kind=entity|mixed 的要点 → 产出结构化契约实体(JSON 块 ```json {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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 {"entities": {...}} ```)
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` 支持场景识别: