dflow-sdd-ddd 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.en.md +57 -43
  3. package/README.md +36 -33
  4. package/TEMPLATE-LANGUAGE-GLOSSARY.md +1 -0
  5. package/bin/dflow.js +4 -8
  6. package/docs/why-dflow.en.md +72 -0
  7. package/docs/why-dflow.md +72 -0
  8. package/lib/init.js +114 -77
  9. package/package.json +2 -2
  10. package/templates/brownfield/references/drift-verification.md +40 -6
  11. package/templates/brownfield/references/finish-feature-flow.md +85 -29
  12. package/templates/brownfield/references/git-integration.md +29 -9
  13. package/templates/brownfield/references/modify-existing-flow.md +61 -0
  14. package/templates/brownfield/references/new-feature-flow.md +62 -1
  15. package/templates/brownfield/references/new-phase-flow.md +19 -1
  16. package/templates/brownfield/references/pr-review-checklist.md +7 -1
  17. package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +46 -33
  18. package/templates/brownfield/scaffolding/Git-principles-gitflow.md +1 -1
  19. package/templates/brownfield/scaffolding/Git-principles-trunk.md +2 -2
  20. package/templates/brownfield/templates/_index.md +23 -4
  21. package/templates/brownfield/templates/context-map.md +12 -4
  22. package/templates/brownfield/templates/lightweight-spec.md +3 -3
  23. package/templates/brownfield/templates/phase-spec.md +3 -3
  24. package/templates/common/references/ddd-modeling-guide.md +837 -0
  25. package/templates/greenfield/references/drift-verification.md +60 -12
  26. package/templates/greenfield/references/finish-feature-flow.md +86 -29
  27. package/templates/greenfield/references/git-integration.md +29 -9
  28. package/templates/greenfield/references/modify-existing-flow.md +23 -0
  29. package/templates/greenfield/references/new-feature-flow.md +70 -8
  30. package/templates/greenfield/references/new-phase-flow.md +15 -1
  31. package/templates/greenfield/references/pr-review-checklist.md +9 -1
  32. package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +40 -33
  33. package/templates/greenfield/scaffolding/Git-principles-gitflow.md +1 -1
  34. package/templates/greenfield/scaffolding/Git-principles-trunk.md +4 -2
  35. package/templates/greenfield/templates/_index.md +23 -4
  36. package/templates/greenfield/templates/aggregate-design.md +8 -1
  37. package/templates/greenfield/templates/context-map.md +13 -4
  38. package/templates/greenfield/templates/events.md +5 -1
  39. package/templates/greenfield/templates/lightweight-spec.md +3 -3
  40. package/templates/greenfield/templates/phase-spec.md +3 -3
  41. package/docs/migrating-to-dflow-v1.md +0 -234
  42. package/templates/greenfield/references/ddd-modeling-guide.md +0 -351
@@ -0,0 +1,72 @@
1
+ # 為什麼用 Dflow(即使 AI 已經會 DDD)
2
+
3
+ > **繁體中文** | [English](why-dflow.en.md)
4
+
5
+ 如果你的直覺是「現在的 AI 已經會 DDD,叫它『用 DDD 建一個 feature』,aggregate、value object、event 都出得來,再加一層 spec-first 工具是不是過度工程?」——這份文件是寫給你的。它不打算說服你,而是把 Dflow 的價值、證據與限制攤平,讓你自己判斷。
6
+
7
+ ## Dflow 是什麼
8
+
9
+ Dflow 不是教 AI 什麼是 DDD——它是一層 scaffold(鷹架):強迫 AI 把每個設計決策的取捨完整留檔,並補上「AI 自己補細節時容易漏、而 review 又難一眼看出」的盲區。
10
+
11
+ 把「AI 會 DDD」拆成兩件事,就懂為什麼還需要它:
12
+
13
+ 1. **AI 能不能說出對的 DDD 答案?** 能。模型讀過教科書,aggregate 邊界、不變式、ubiquitous language 都答得出來。
14
+ 2. **AI 在你 review 時會不會留下夠完整的紀錄(它考慮過哪些、為何這樣選、哪裡還沒確定)讓你 audit、會不會主動 catch 它自己容易漏的陷阱?** 不一定,要看你怎麼問。
15
+
16
+ 所以真正該比的不是「AI 工具 vs process」,而是:
17
+
18
+ - **AI alone** 的產出 = AI 知識 × 隱含的 prompt 結構 × 你 review 它的能力
19
+ - **AI + Dflow** 的產出 = AI 知識 × **明確的 elicitation scaffold** × **可審查的決策紀錄(取捨、否決的方案、open questions)** × 你 review 它的能力
20
+
21
+ 差異不是「更聰明的 AI」,是「**更可審查的 AI**」。
22
+
23
+ ## Dflow 的 DDD 引導是「長出來的」,不是抄教科書
24
+
25
+ Dflow 的價值有一部分在於:它的引導是從真實盲區回灌的,而且補上之後,模型真的會在後續主動沿用。一個具體、可檢查的例子——
26
+
27
+ **盲區**:模型自己建模時,把「一個充電槍同時只能有一筆進行中 session」這條唯一性規則,只用 aggregate 內 `if Status == InUse throw` 的 in-memory check 保護。DDD 教科書角度這是對的,但並發下兩個請求各自讀到 `Available`、各自通過檢查、各自 save → 不變式被破壞(modeling-correct、production-broken)。
28
+
29
+ **回灌**:把這個盲區寫成一段引導,補進 Dflow 的 `ddd-modeling-guide.md`——「Set-Based / Uniqueness Invariants」:這類「同 X 只能有一筆 active」的規則,無論怎麼切 aggregate,in-memory check 在並發下永遠不夠,要加 DB unique / partial index 或 concurrency token,並把衝突 translate 成 HTTP 409。
30
+
31
+ **沿用**:換一個 domain(冷鏈感測器「一個 sensor 同時最多掛在一個 carton」,結構平行)、且讓模型不知道自己在被測,它就**主動引用那段**、補上完整三層保護(in-memory guard + concurrency token + DB partial unique index + 409)。
32
+
33
+ 這證明的是一件具體的事:**Dflow 把「AI 自己會漏的盲區」固化成可重用、會被遵循的引導。** 這不是「跑幾次就證明 AI+process 全面勝出」那種大結論(樣本很小);它是 Dflow 引導迴路有效的證據——**盲區 → 補引導 → 模型沿用**。你也能自己複現(見文末)。
34
+
35
+ > 補充誠實度:兩次 run 之間 domain 與 framing 也不同,但都不利於「不是引導的功勞」這個反方——兩個 domain 在模型的 pre-training 裡都不是高頻並發設計題材;framing 在第二次更純(不知被測),若它是主因,結果該更差而非更好。把這兩條的可能性壓低後,最能解釋這個翻轉的就剩那段引導在不在。
36
+
37
+ ## 其他幾個「Dflow 強制留檔、AI 自己容易漏」
38
+
39
+ 同一輪觀察裡還看到(每點都是「Dflow 做了什麼 → 沒它會怎樣」):
40
+
41
+ - **強迫寫否決理由**:aggregate 設計模板一句 prompt,誘出「選這個邊界 + 理由 + 考慮過哪些替代 + 為何否決」的完整決策段;AI 自己通常只給你一個方案,review 時你無法 audit「它想過 X 嗎」。
42
+ - **Step gate 把決策變成 reviewable moment**:模型在命名、模型 spike 等節點自然停下等確認;AI 自己一路寫到 code、tests 都好了你才有機會 review,這時 aggregate 邊界已經沒有商量空間。
43
+ - **Open Question 留檔給 domain expert**:模型把不確定的點列成 OQ 等人答,而不是「不確定就猜一個合理的」把假設藏進 code。
44
+ - **Ubiquitous language 不漂移**:術語表 + code mapping 讓 spec / model / code 用同一組名詞;AI 自己一段話內就能混用 Sensor / Device / Tracker。
45
+ - **規則可被查詢**:每條 business rule 有 ID、status、所屬 aggregate、behavior 連結;AI 自己把規則散在 prose 裡,「BR-003 影響哪些測試」只能用 grep 推敲。
46
+
47
+ ## 誠實的取捨
48
+
49
+ 不掩蓋限制,反而是這套論點的可信來源:
50
+
51
+ - **scope**:目前觀察只跑在單一模型 × 幾個中等複雜度 domain × lightweight modeling scope(到領域建模、不含 implementation phase)。implementation 階段的引導是否同樣有效、換不同模型會不會一樣,**未驗**。
52
+ - **先驗**:被測模型本來就對 DDD 有 pre-training 先驗。Dflow 證明的是「能讓『會、但不一定每次仔細想』變成『仔細想』」,**不是**「能讓完全不懂 DDD 的 AI 變會」。
53
+ - **採用即承諾遵循**:Dflow 是 spec-first 工具,只在被遵循時有效;AI 或人故意走偏的情境不在宣稱範圍內。這是工具屬性,不是 bug。
54
+
55
+ 對需要 audit 的場景——醫療、金融、合規、安全敏感、或任何「上線出包代價高 / 個人責任重」的領域——這個 reviewability 差異是 deal-breaker。成本要分兩塊看:**產出** DDD 文件已經不是徒手做 DDD 的高人力年代——AI 幫你生規格、決策紀錄與領域模型,邊際成本主要是多花一些 token 與走一遍流程;而且光是「生成時被領域模型約束」就已讓產出更穩(如上面那個並發盲區),這層就算你沒深讀紀錄也拿得到。Dflow 的 DDD 也刻意務實裁剪(不是學院派全套)、適合一般公司的中型系統,採用門檻不高(不需要團隊先是 DDD 專家)。但要再兌現「可審查」這份價值,得有人實際去 review 那份紀錄——那才是人力與紀律的關鍵成本。所以取捨仍在、值得討論:高風險、需 audit、要長期維護時,這筆投資明顯划算;你不會去 review、失敗成本低、迭代又快時,AI alone 仍可能是更實際的選擇,不是 Dflow 一定贏。
56
+
57
+ ## 自己驗證
58
+
59
+ 不用相信任何說法,自己跑一次(約 10–30 分鐘):
60
+
61
+ ```bash
62
+ npm install -g dflow-sdd-ddd
63
+ mkdir dflow-test && cd dflow-test
64
+ git init && git commit --allow-empty -m "init"
65
+ dflow init # 選 greenfield + 你的 AI 工具 + 你的 stack
66
+ ```
67
+
68
+ 接著在你的 AI coding agent 裡跑 `/dflow:new-feature`,指派一個含「跨實例唯一」型不變式的 feature,例如「同帳號同時最多一個 active session」。看模型走到領域建模時,會不會寫到「Set-Based / Uniqueness Invariants」段(Dflow 的 `ddd-modeling-guide.md`)、cite 它、並補上 DB unique / partial index + concurrency token + 409。注意:裝最新版能驗證的是「引導在場時模型確實會用它」這一半;「引導不在場時模型會漏」那一半是上面那段在加入引導之前建立的,不是你在最新版上能切換的——最新版一律含這段。
69
+
70
+ ---
71
+
72
+ Dflow 不宣稱讓 AI 更聰明,它讓 AI 更可審查:規格優先、領域語義顯式化、把決策與否決理由留檔、在實作前約束、完成前驗證漂移(drift)。為什麼領域語義本身在 AI 時代更關鍵,見 [為什麼 AI 時代 DDD 更重要](why-ddd-for-ai.md)。
package/lib/init.js CHANGED
@@ -26,6 +26,13 @@ const AGENT_SHIM_SECTION_END = '<!-- dflow-generated: agent-shim END -->';
26
26
  const WORKFLOW_BUNDLE_DEST = 'dflow/specs/shared/dflow-workflows';
27
27
  const WORKFLOW_BUNDLE_MANIFEST_PATH = `${WORKFLOW_BUNDLE_DEST}/.dflow-bundle-manifest.json`;
28
28
  const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
29
+ // Files the common bundle tree (templates/common/) MUST provide. A missing
30
+ // common file is a broken package, NOT a retired bundle file: without this guard
31
+ // listBundleSourceFiles would return a smaller-but-"valid" set lacking the file,
32
+ // and configure-agents stale-removal would then DELETE the already-installed
33
+ // copy from the user's project (it diffs as "retired"). PROPOSAL-064 fresh-gate
34
+ // finding. Guarded before any stale cleanup / manifest write.
35
+ const REQUIRED_COMMON_BUNDLE_FILES = ['references/ddd-modeling-guide.md'];
29
36
  const EXPECTED_COMMAND_IDS = [
30
37
  'new-feature',
31
38
  'modify-existing',
@@ -399,13 +406,6 @@ async function runPreflight(cwd) {
399
406
  warnings.push('Found empty dflow/specs/. Continuing because no initialized files were found.');
400
407
  }
401
408
 
402
- const legacySpecsPath = path.join(cwd, 'specs');
403
- if ((await pathExists(legacySpecsPath)) && (await containsInitializedContent(legacySpecsPath))) {
404
- warnings.push(
405
- 'Detected legacy specs/. Dflow V1 will not migrate or modify it; new files will be created under dflow/specs/. See docs/migrating-to-dflow-v1.md for the manual migration checklist.'
406
- );
407
- }
408
-
409
409
  await assertWritableProjectRoot(cwd);
410
410
 
411
411
  if (compareVersions(process.versions.node, MIN_NODE_VERSION) < 0) {
@@ -1214,31 +1214,97 @@ async function finalizePlanItems(cwd, items) {
1214
1214
  }
1215
1215
  }
1216
1216
 
1217
+ // A workflow bundle file name must be unique across the common and edition
1218
+ // source trees, so the merged dest path / manifest entry never collide or
1219
+ // shadow each other. Pure (no I/O) so it is unit-testable on a synthetic list.
1220
+ function assertNoBundleCollision(files) {
1221
+ const seenBy = new Map();
1222
+ for (const f of files) {
1223
+ const prior = seenBy.get(f.sourceRel);
1224
+ if (prior && prior !== f.sourceRoot) {
1225
+ throw new InitError(
1226
+ `Internal error: workflow bundle file "${f.sourceRel}" exists in both templates/${prior}/ and templates/${f.sourceRoot}/. A bundle file name must be unique across the common and edition source trees.`
1227
+ );
1228
+ }
1229
+ seenBy.set(f.sourceRel, f.sourceRoot);
1230
+ }
1231
+ }
1232
+
1233
+ // R3-02 (sharpened for the PROPOSAL-064 common merge): the EDITION tree itself
1234
+ // must contribute both the flow docs (references/) and the blank templates
1235
+ // (templates/) — the common tree must NOT mask a broken edition (a vanished
1236
+ // templates/{edition}/references/ would otherwise be hidden by common's
1237
+ // non-empty references/). Enforced in the scanner (not only the projector) so
1238
+ // BOTH callers are covered: the projector hard-fails, and doctor's existing
1239
+ // try/catch around listBundleSourceFiles degrades to skipping the orphan scan
1240
+ // rather than mis-reporting every projected flow file as orphaned. Pure (no
1241
+ // I/O) so it is unit-testable on a synthetic list.
1242
+ function assertEditionBundleComplete(files, edition) {
1243
+ const hasEditionRefs = files.some((f) => f.sourceRoot === edition && f.dir === 'references');
1244
+ const hasEditionTemplates = files.some((f) => f.sourceRoot === edition && f.dir === 'templates');
1245
+ if (!hasEditionRefs || !hasEditionTemplates) {
1246
+ throw new InitError(
1247
+ `Internal error: incomplete workflow bundle source for edition "${edition}" (expected files under both templates/${edition}/references/ and templates/${edition}/templates/). The installed dflow package looks incomplete.`
1248
+ );
1249
+ }
1250
+ }
1251
+
1252
+ // Companion to assertEditionBundleComplete for the common tree: every file the
1253
+ // common bundle MUST provide has to be present. A common file silently missing
1254
+ // (broken package / tarball) would otherwise slip through as a smaller-but-valid
1255
+ // set and, on re-projection, be DELETED from the user's project by stale-removal
1256
+ // (the manifest diff would classify the still-installed copy as "retired").
1257
+ // Hard-fail here, before stale cleanup / manifest write. Pure (no I/O) so it is
1258
+ // unit-testable on a synthetic list.
1259
+ function assertCommonBundleComplete(files) {
1260
+ const present = new Set(files.filter((f) => f.sourceRoot === 'common').map((f) => f.sourceRel));
1261
+ for (const required of REQUIRED_COMMON_BUNDLE_FILES) {
1262
+ if (!present.has(required)) {
1263
+ throw new InitError(
1264
+ `Internal error: missing required common workflow bundle file templates/common/${required}. The installed dflow package looks incomplete.`
1265
+ );
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ // Bundle source files come from two trees, merged: the edition-neutral common
1271
+ // tree (templates/common/, PROPOSAL-064) and the per-edition tree
1272
+ // (templates/{edition}/). Each descriptor carries its sourceRoot so the reader
1273
+ // (readPackagedBundleFile) loads content from the right tree; the projected dest
1274
+ // path and the manifest stay keyed on sourceRel, so a common-sourced file lands
1275
+ // at the same dflow/.../references/<name> path in every edition. The scanner
1276
+ // validates the merged set (collision + complete-edition guards) before
1277
+ // returning, so both callers (projector, doctor) get a trustworthy list.
1217
1278
  async function listBundleSourceFiles(edition) {
1218
1279
  const bundleDirs = ['references', 'templates'];
1280
+ const sourceRoots = ['common', edition];
1219
1281
  const files = [];
1220
1282
 
1221
- for (const dir of bundleDirs) {
1222
- const sourceDir = path.join(TEMPLATE_ROOT, edition, dir);
1223
- let entries;
1224
- try {
1225
- entries = await fs.readdir(sourceDir);
1226
- } catch (error) {
1227
- if (error.code === 'ENOENT') {
1228
- continue;
1283
+ for (const sourceRoot of sourceRoots) {
1284
+ for (const dir of bundleDirs) {
1285
+ const sourceDir = path.join(TEMPLATE_ROOT, sourceRoot, dir);
1286
+ let entries;
1287
+ try {
1288
+ entries = await fs.readdir(sourceDir);
1289
+ } catch (error) {
1290
+ if (error.code === 'ENOENT') {
1291
+ continue;
1292
+ }
1293
+ throw error;
1229
1294
  }
1230
- throw error;
1231
- }
1232
- for (const entry of entries) {
1233
- const sourceRel = `${dir}/${entry}`;
1234
- const sourcePath = path.join(sourceDir, entry);
1235
- const stat = await fs.stat(sourcePath);
1236
- if (stat.isFile()) {
1237
- files.push({ sourceRel, dir, name: entry });
1295
+ for (const entry of entries) {
1296
+ const sourcePath = path.join(sourceDir, entry);
1297
+ const stat = await fs.stat(sourcePath);
1298
+ if (stat.isFile()) {
1299
+ files.push({ sourceRel: `${dir}/${entry}`, dir, name: entry, sourceRoot });
1300
+ }
1238
1301
  }
1239
1302
  }
1240
1303
  }
1241
1304
 
1305
+ assertNoBundleCollision(files);
1306
+ assertEditionBundleComplete(files, edition);
1307
+ assertCommonBundleComplete(files);
1242
1308
  return files;
1243
1309
  }
1244
1310
 
@@ -1306,18 +1372,12 @@ function injectBundleMarker(content) {
1306
1372
  }
1307
1373
 
1308
1374
  async function addWorkflowBundleItems(cwd, items, warnings, edition) {
1375
+ // listBundleSourceFiles merges templates/common/ + templates/{edition}/ and
1376
+ // validates the merged set (collision + complete-edition guards) before
1377
+ // returning, so the projector can trust a complete set here. A broken package
1378
+ // throws (InitError) before any stale removal or manifest write.
1309
1379
  const bundleFiles = await listBundleSourceFiles(edition);
1310
1380
 
1311
- // R3-02: a healthy edition's bundle source is never empty. An empty scan means
1312
- // a broken installed package — hard-fail BEFORE scheduling any removal or
1313
- // writing the manifest, so we never overwrite the manifest with files:[]
1314
- // (which would orphan every projected file and ship a workflow-less project).
1315
- if (bundleFiles.length === 0) {
1316
- throw new InitError(
1317
- `Internal error: no workflow bundle source files found for edition "${edition}" (expected files under templates/${edition}/references/ and templates/${edition}/templates/). The installed dflow package looks incomplete.`
1318
- );
1319
- }
1320
-
1321
1381
  const newRelPaths = new Set(bundleFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
1322
1382
 
1323
1383
  // Read the previous manifest to drive stale cleanup. Distinguish absent
@@ -1396,10 +1456,10 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
1396
1456
  }
1397
1457
 
1398
1458
  // Build items for current edition bundle files.
1399
- for (const { sourceRel } of bundleFiles) {
1459
+ for (const { sourceRel, sourceRoot } of bundleFiles) {
1400
1460
  const relativePath = `${WORKFLOW_BUNDLE_DEST}/${sourceRel}`;
1401
1461
  const absolutePath = path.join(cwd, relativePath);
1402
- const sourceContent = await readPackagedBundleFile(edition, sourceRel);
1462
+ const sourceContent = await readPackagedBundleFile(sourceRoot, sourceRel);
1403
1463
  const content = injectBundleMarker(sourceContent);
1404
1464
 
1405
1465
  let action;
@@ -1423,7 +1483,7 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
1423
1483
 
1424
1484
  items.push({
1425
1485
  relativePath,
1426
- source: `packaged-bundle:${edition}/${sourceRel}`,
1486
+ source: `packaged-bundle:${sourceRoot}/${sourceRel}`,
1427
1487
  notes,
1428
1488
  content,
1429
1489
  action,
@@ -1455,13 +1515,17 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
1455
1515
  }
1456
1516
  }
1457
1517
 
1458
- async function readPackagedBundleFile(edition, sourceRel) {
1459
- const filePath = path.join(TEMPLATE_ROOT, edition, sourceRel);
1460
- const normalizedRoot = path.resolve(TEMPLATE_ROOT, edition);
1518
+ // Reads one bundle file's content from its source tree. sourceRoot is the
1519
+ // descriptor's tree ('common' or an edition) so a common-sourced file is read
1520
+ // from templates/common/, not templates/{edition}/ (PROPOSAL-064). The traversal
1521
+ // guard is re-rooted at templates/${sourceRoot}/ accordingly.
1522
+ async function readPackagedBundleFile(sourceRoot, sourceRel) {
1523
+ const filePath = path.join(TEMPLATE_ROOT, sourceRoot, sourceRel);
1524
+ const normalizedRoot = path.resolve(TEMPLATE_ROOT, sourceRoot);
1461
1525
  const normalizedFilePath = path.resolve(filePath);
1462
1526
 
1463
1527
  if (!normalizedFilePath.startsWith(`${normalizedRoot}${path.sep}`)) {
1464
- throw new InitError(`Internal error: packaged bundle file not found: templates/${edition}/${sourceRel}`);
1528
+ throw new InitError(`Internal error: packaged bundle file not found: templates/${sourceRoot}/${sourceRel}`);
1465
1529
  }
1466
1530
 
1467
1531
  let buffer;
@@ -1469,15 +1533,15 @@ async function readPackagedBundleFile(edition, sourceRel) {
1469
1533
  buffer = await fs.readFile(normalizedFilePath);
1470
1534
  } catch (error) {
1471
1535
  if (error.code === 'ENOENT') {
1472
- throw new InitError(`Internal error: packaged bundle file not found: templates/${edition}/${sourceRel}`);
1536
+ throw new InitError(`Internal error: packaged bundle file not found: templates/${sourceRoot}/${sourceRel}`);
1473
1537
  }
1474
- throw new InitError(`Internal error: cannot read packaged bundle file: templates/${edition}/${sourceRel}`);
1538
+ throw new InitError(`Internal error: cannot read packaged bundle file: templates/${sourceRoot}/${sourceRel}`);
1475
1539
  }
1476
1540
 
1477
1541
  try {
1478
1542
  return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
1479
1543
  } catch {
1480
- throw new InitError(`Internal error: invalid UTF-8 packaged bundle file: templates/${edition}/${sourceRel}`);
1544
+ throw new InitError(`Internal error: invalid UTF-8 packaged bundle file: templates/${sourceRoot}/${sourceRel}`);
1481
1545
  }
1482
1546
  }
1483
1547
 
@@ -3056,8 +3120,6 @@ async function runDoctor(options = {}) {
3056
3120
  }
3057
3121
 
3058
3122
  const findings = [];
3059
- await checkLegacyRootSpecsDir(cwd, findings);
3060
- await checkLegacySharedDir(cwd, findings);
3061
3123
  await checkConventionsDflowVersion(cwd, findings);
3062
3124
  await checkOrphanedWorkflowBundleFiles(cwd, findings);
3063
3125
 
@@ -3073,36 +3135,6 @@ async function runDoctor(options = {}) {
3073
3135
  }
3074
3136
  }
3075
3137
 
3076
- async function checkLegacyRootSpecsDir(cwd, findings) {
3077
- const legacyPath = path.join(cwd, 'specs');
3078
- if ((await pathExists(legacyPath)) && (await containsInitializedContent(legacyPath))) {
3079
- findings.push({
3080
- level: 'warn',
3081
- title: 'Legacy specs/ directory at project root',
3082
- detail: 'V1 layout uses dflow/specs/ instead. The CLI does not modify root specs/.',
3083
- action: 'See docs/migrating-to-dflow-v1.md (Step 1) for the manual migration steps.'
3084
- });
3085
- }
3086
- }
3087
-
3088
- async function checkLegacySharedDir(cwd, findings) {
3089
- const candidates = [
3090
- path.join(cwd, 'dflow', 'specs', '_共用'),
3091
- path.join(cwd, 'specs', '_共用')
3092
- ];
3093
- for (const candidate of candidates) {
3094
- if (await pathExists(candidate)) {
3095
- const rel = normalizePath(path.relative(cwd, candidate));
3096
- findings.push({
3097
- level: 'warn',
3098
- title: `Legacy ${rel}/ directory`,
3099
- detail: 'V1 layout uses shared/ (canonical English directory name).',
3100
- action: 'See docs/migrating-to-dflow-v1.md (Step 2) for the rename steps.'
3101
- });
3102
- }
3103
- }
3104
- }
3105
-
3106
3138
  async function checkConventionsDflowVersion(cwd, findings) {
3107
3139
  const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
3108
3140
  if (!(await pathExists(conventionsPath))) return;
@@ -3185,7 +3217,7 @@ function printDoctorReport(stdout, cwd, findings) {
3185
3217
  stdout.write(`Project: ${cwd}\n\n`);
3186
3218
 
3187
3219
  if (findings.length === 0) {
3188
- stdout.write('All checks passed. No legacy artifacts detected.\n');
3220
+ stdout.write('All checks passed. No Dflow health findings detected.\n');
3189
3221
  return;
3190
3222
  }
3191
3223
 
@@ -3211,5 +3243,10 @@ module.exports = {
3211
3243
  // Exported for tests: the write phase enforces the PROPOSAL-054 raw-equality guard
3212
3244
  // for user-owned root agent files (changed-after-preview -> skip), which cannot be
3213
3245
  // exercised through the CLI because preview and write happen in one process.
3214
- writeFilePlan
3246
+ writeFilePlan,
3247
+ // Exported for tests (PROPOSAL-064): pure bundle-source guards, unit-tested on
3248
+ // synthetic descriptor lists without touching the packaged templates/ tree.
3249
+ assertNoBundleCollision,
3250
+ assertEditionBundleComplete,
3251
+ assertCommonBundleComplete
3215
3252
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dflow-sdd-ddd",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Spec-first SDD/DDD workflow kit for AI-assisted development",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "homepage": "https://github.com/weilung/dflow-sdd-ddd#readme",
41
41
  "scripts": {
42
- "test": "node test/smoke.mjs && node test/registry-parity.mjs && node test/agent-inject.mjs"
42
+ "test": "node test/smoke.mjs && node test/registry-parity.mjs && node test/agent-inject.mjs && node test/bundle-guards.mjs"
43
43
  },
44
44
  "license": "AGPL-3.0-or-later",
45
45
  "publishConfig": {
@@ -4,18 +4,23 @@ Triggered by `/dflow:verify` or `/dflow:verify <bounded-context>`.
4
4
 
5
5
  ## Purpose
6
6
 
7
- The A+C structure (`rules.md` as index + `behavior.md` as scenario content) introduces a drift risk — the two files can fall out of sync. This command provides a mechanical verification safety net that developers can run at key moments: before a PR, after a refactor, or when onboarding to an unfamiliar Bounded Context.
7
+ The A+C structure (`rules.md` as index + `behavior.md` as scenario content) introduces a drift risk — the two files can fall out of sync. This command's **core** is a mechanical safety net for that `rules.md` ↔ `behavior.md` correspondence, run at key moments: before a PR, after a refactor, or when onboarding to an unfamiliar Bounded Context. On top of the core it also runs an **optional, non-blocking domain-doc hygiene check** on the model catalog (`models.md`) — see Scope.
8
8
 
9
9
  ## Scope
10
10
 
11
- ### This command does (mechanical layer)
11
+ ### This command does (core + optional hygiene)
12
12
 
13
- Three string-matching checks that AI can perform deterministically:
13
+ A small **core** of three deterministic string-matching checks on the
14
+ `rules.md` ↔ `behavior.md` correspondence:
14
15
 
15
16
  1. **BR-ID forward check**: Every `BR-*` declared in `rules.md` has a corresponding section in `behavior.md`
16
17
  2. **Anchor validity**: If `rules.md` links to `behavior.md#section`, that anchor exists
17
18
  3. **BR-ID reverse check**: Every `BR-*` referenced in `behavior.md` is declared in `rules.md`
18
19
 
20
+ Plus an **optional domain-doc hygiene** warning (non-blocking — it never fails the
21
+ command, only surfaces a "confirm this is intentional" signal): the `models.md`
22
+ Code-Mapping hygiene check (see Model Catalog Notes).
23
+
19
24
  ### This command does NOT do (semantic layer — explicitly excluded)
20
25
 
21
26
  Semantic verification (LLM reads the one-line summary in `rules.md` vs the Given/When/Then in `behavior.md` and judges whether they contradict) is **out of scope**. Reasons:
@@ -39,8 +44,8 @@ Reasons:
39
44
  Step 3
40
45
  - BC-level current state is already maintained by `rules.md` /
41
46
  `behavior.md`, written by the same `/dflow:finish-feature` Step 3
42
- - `/dflow:verify` keeps a small, mechanical scope: just the
43
- `rules.md` ↔ `behavior.md` correspondence inside one BC
47
+ - `/dflow:verify` keeps a small core: the `rules.md` ↔ `behavior.md`
48
+ correspondence inside one BC, plus the optional models.md hygiene check below
44
49
  - Cross-feature / cross-phase aggregation would mix `/dflow:verify`'s
45
50
  job with `/dflow:finish-feature`'s job and produce false positives
46
51
  during in-progress features
@@ -84,6 +89,9 @@ For each Bounded Context:
84
89
  - behavior.md → templates/behavior.md
85
90
  Or run the completion flow to populate it from existing completed specs
86
91
  ```
92
+ - Also locate the **optional** hygiene input for this BC — `models.md`. It feeds
93
+ the non-blocking Model Catalog Notes hygiene check. If it is absent, **skip the
94
+ check silently** — do not report or stop; it is bonus, not part of the core.
87
95
 
88
96
  ### Step 2: Extract BR-IDs from rules.md
89
97
 
@@ -153,6 +161,31 @@ Issues:
153
161
  remove the stale scenario reference from behavior.md
154
162
  ```
155
163
 
164
+ The optional `models.md` Code-Mapping hygiene check (below) appends its
165
+ non-blocking `ℹ` signals to this same report and never changes the core
166
+ pass / fail count.
167
+
168
+ ## Model Catalog Notes
169
+
170
+ A non-blocking **informational** hygiene check on `models.md`:
171
+
172
+ - For each row whose **primary name cell** holds a real value, if the
173
+ `Code Mapping` column is empty or still a `{Namespace.Class}` placeholder,
174
+ surface it:
175
+ ```
176
+ ℹ models.md: Entity "ExpenseReport" has no Code Mapping yet — link it if the
177
+ code is extracted; if extraction is deferred, this is expected
178
+ ```
179
+ - **Skip untouched seed rows by the name cell only**: a row whose name is still a
180
+ `{...}` placeholder is seed scaffolding. But a row with a **real name** and a
181
+ placeholder / empty Code Mapping is exactly the one to surface — do not skip it
182
+ just because that one cell still holds `{...}`.
183
+ - This is **informational, not drift** — Brownfield records a concept in `models.md`
184
+ during discovery, often *before* the code is extracted, so an empty Code Mapping
185
+ is a normal state, not drift. An explicit "planned / deferred" note on the row, or
186
+ a matching `## Code Mapping Notes` entry, counts as accounted for; do not surface
187
+ it.
188
+
156
189
  ## When to Run
157
190
 
158
191
  Recommended trigger points (not enforced — developer's judgment):
@@ -166,7 +199,8 @@ Recommended trigger points (not enforced — developer's judgment):
166
199
  ## Path Assumptions
167
200
 
168
201
  This command operates entirely within `dflow/specs/domain/{context}/` files
169
- (`rules.md` and `behavior.md`). It does **not** read from
202
+ (`rules.md` and `behavior.md` for the core check; `models.md` for the optional
203
+ hygiene warning). It does **not** read from
170
204
  `dflow/specs/features/active/{SPEC-ID}-{slug}/` directories — the feature
171
205
  directory layout is not part of verify's input.
172
206
 
@@ -55,6 +55,10 @@ proceeding (do not flip status, do not archive, do not emit summary).
55
55
  - [ ] Every phase-spec file referenced in the Phase Specs table exists at
56
56
  the path the table claims
57
57
  - [ ] Every phase-spec file's frontmatter has `status: completed`
58
+ - [ ] Every Tier = T2 row in `_index.md` Lightweight Changes references an
59
+ existing `lightweight-*.md` / `BUG-*.md` file in the feature directory
60
+ - [ ] Every such lightweight / BUG spec file's frontmatter has
61
+ `status: completed`
58
62
  - [ ] `_index.md` has no obvious open items in Resume Pointer (e.g. "phase-N
59
63
  drafting" / "implementation pending" / "TODO" markers)
60
64
  - [ ] Current BR Snapshot table is non-empty (or feature is intentionally
@@ -65,6 +69,7 @@ If any check fails:
65
69
  > found:
66
70
  > ✗ phase-spec-2026-04-15-foo.md status is still `in-progress`
67
71
  > ✗ Phase Specs table row 3 references missing file phase-spec-...
72
+ > ✗ lightweight-2026-06-20-rounding.md frontmatter status is still `in-progress`
68
73
  >
69
74
  > Address these (run `/dflow:new-phase` to add missing work, or fix the
70
75
  > stale status manually), then re-run `/dflow:finish-feature`."
@@ -92,11 +97,17 @@ branch: feature/{SPEC-ID}-{slug}
92
97
  ---
93
98
  ```
94
99
 
95
- Also update the **Resume Pointer** to reflect closeout:
100
+ Also update the **Resume Pointer** to reflect closeout — this writes the
101
+ cursor's terminal state (after closeout no workflow is active on this
102
+ feature; do not edit the cursor again after the Step 4 closeout commit):
96
103
 
97
104
  ```
98
105
  **Current Progress**: feature completed ({date}); all phase-specs status = completed.
99
106
  **Next Action**: integration — push / merge / PR per the selected Git policy.
107
+ **Active Workflow**: none
108
+ **Current Step**: n/a
109
+ **Gates Passed**: n/a
110
+ **Awaiting**: none
100
111
  ```
101
112
 
102
113
  **→ Transition (step-internal)**: Step 2 complete. Announce "Step 2 complete (status flipped). Entering Step 3: Sync BR Snapshot to BC layer." and continue.
@@ -170,7 +181,8 @@ AI runs:
170
181
  ```bash
171
182
  git mv dflow/specs/features/active/{SPEC-ID}-{slug} \
172
183
  dflow/specs/features/completed/{SPEC-ID}-{slug}
173
- git status # confirm rename detection
184
+ git status # confirm rename detection AND check for `RM` — an `M` next to
185
+ # a rename means unstaged edits you must re-add before committing
174
186
  ```
175
187
 
176
188
  `git mv` is mandatory — never use plain `mv` + `git add`. This preserves
@@ -179,37 +191,74 @@ PR diff quality stays intact across the move. See
179
191
  `references/git-integration.md` § "Directory Moves Must Use git mv" for
180
192
  the full rule set.
181
193
 
182
- After the move, also `git add` any modified files from Step 3 (the
183
- updated `rules.md`, `behavior.md`, `glossary.md`, `tech-debt.md`, etc.)
184
- into the same stage.
185
-
186
194
  **Closeout commit checkpoint** (completes the offline Local-closeout gate):
187
195
 
188
196
  ```
189
- ✓ Feature archived to completed/ and closeout files staged
197
+ ✓ Feature archived to completed/ and closeout ready to stage
190
198
  Commit this closeout now?
191
199
  [Y] Yes — the AI commits with your Git identity (marker per _conventions.md § AI Commit Policy)
192
200
  [N] No — skip; you commit yourself
193
201
  ```
194
202
 
195
- Whether you choose Y or N, record one row in the feature `_index.md`
196
- Checkpoint Log (`closeout | committed ({hash})` or `closeout | skipped`). Only
197
- write a hash after the commit actually succeeds; if a pre-commit hook rejects it
198
- or the commit fails, record `failed` and surface the error — never write a fake
199
- hash.
200
-
201
- The Local-closeout gate is satisfied **only when the closeout is committed**:
202
- closeout complete, Checkpoint Log updated, and the working tree clean (no
203
- uncommitted changes). If you declined the commit (chose N) or it failed,
204
- Local-closeout is **not** satisfied yet — commit the staged closeout yourself
205
- before continuing; do not enter the Integration / PR gate with uncommitted
206
- changes. Once committed, the gate stands on its own offline; integration happens
207
- in Step 5 when you have network.
208
-
209
- **→ Transition (step-internal)**: Step 4 complete. Branch on whether the closeout commit landed:
210
-
211
- - **Closeout commit landed (working tree clean)** → announce "Step 4 complete (feature archived; Local-closeout gate satisfied). Entering Step 5: Integration / PR gate." and continue.
212
- - **Closeout commit was declined (N) or failed** → **stop here.** Announce "Step 4 complete (feature archived), but the Local-closeout gate is not satisfied yet — the closeout is staged but uncommitted. Commit those changes (or address the failure), then resume to Step 5." Do **not** enter Step 5 with uncommitted closeout changes.
203
+ Then, in this order:
204
+
205
+ 1. **Record the checkpoint row first.** Write one row in the moved
206
+ `_index.md` Checkpoint Log — `closeout | committed` for Y, `closeout |
207
+ skipped` for N. The closeout row carries **no commit hash**: the closeout
208
+ commit cannot contain its own hash. Trace it later via
209
+ `git log -1 -- dflow/specs/features/completed/{SPEC-ID}-{slug}` (or the
210
+ optional `Dflow-Checkpoint` trailer). The "hash only after success" rule
211
+ still applies to spec / implementation rows — closeout is the documented
212
+ exception (see `references/git-integration.md` § Commit Checkpoints,
213
+ Branch Gate & AI Commits).
214
+ 2. **Stage the whole archived feature directory:**
215
+
216
+ ```bash
217
+ git add dflow/specs/features/completed/{SPEC-ID}-{slug}
218
+ ```
219
+
220
+ This is required, not optional: `git mv` stages the rename with the
221
+ **last-committed** content, so working-tree edits made earlier in this
222
+ flow to the moved files — the Step 2 status flip and Resume Pointer
223
+ update, plus the checkpoint row you just wrote — stay **unstaged** until
224
+ this `git add`. In `git status`, the moved `_index.md` showing `RM`
225
+ instead of plain `R` is exactly this signal. Then also `git add` the
226
+ files updated in Step 3 (the updated `rules.md`, `behavior.md`,
227
+ `glossary.md`, `tech-debt.md`, etc.) into the same stage.
228
+ 3. **Commit (Y) or stop (N).** For Y the AI commits. If a pre-commit hook
229
+ rejects it or the commit fails, flip the checkpoint row to `failed` (the
230
+ row is not committed yet — edit it directly), surface the error, and
231
+ treat the gate as unsatisfied.
232
+
233
+ **Post-commit closeout verification** — after a successful commit, and before
234
+ declaring the Local-closeout gate satisfied, AI runs and reports `✓` / `✗` for
235
+ every item:
236
+
237
+ - [ ] `git show HEAD:dflow/specs/features/completed/{SPEC-ID}-{slug}/_index.md`
238
+ — one blob read verifying **two** things: frontmatter `status: completed`
239
+ **and** the Checkpoint Log contains the closeout row. This reads the
240
+ **committed** content, not the working tree — the former catches "rename
241
+ carried stale content", the latter catches "row never made it into the
242
+ commit".
243
+ - [ ] `dflow/specs/features/active/{SPEC-ID}-{slug}/` no longer exists (the
244
+ directory was moved, not copied)
245
+ - [ ] `git status --short` shows no leftovers related to this feature
246
+ (working tree clean; identify any unrelated dirty files explicitly)
247
+
248
+ If any item fails, do **not** declare closeout complete — fix it (re-add and
249
+ amend, or a follow-up commit; the developer chooses) and re-verify.
250
+
251
+ The Local-closeout gate is satisfied **only when the closeout is committed and
252
+ the verification above passes**. If you declined the commit (chose N) or it
253
+ failed, Local-closeout is **not** satisfied yet — commit the staged closeout
254
+ yourself before continuing; do not enter the Integration / PR gate with
255
+ uncommitted changes. Once committed and verified, the gate stands on its own
256
+ offline; integration happens in Step 5 when you have network.
257
+
258
+ **→ Transition (step-internal)**: Step 4 complete. Branch on the verification result:
259
+
260
+ - **Closeout commit landed and post-commit verification passed** → announce "Step 4 complete (feature archived; Local-closeout gate satisfied). Entering Step 5: Integration / PR gate." and continue.
261
+ - **Closeout commit was declined (N), failed, or verification reported `✗`** → **stop here.** Announce "Step 4 complete (feature archived), but the Local-closeout gate is not satisfied yet — the closeout is staged but uncommitted, or the committed content failed verification. Commit the staged changes (or fix the failure), then resume to Step 5." Do **not** enter Step 5 with uncommitted or unverified closeout changes.
213
262
 
214
263
  ## Step 5: Emit Integration Summary (Git-strategy-neutral)
215
264
 
@@ -266,10 +315,17 @@ the developer:
266
315
 
267
316
  If no `follow-up-of` field, skip Step 6 and announce closeout complete:
268
317
  > "`/dflow:finish-feature` complete for `{SPEC-ID}-{slug}`. Feature
269
- > directory is now at `dflow/specs/features/completed/{SPEC-ID}-{slug}/`.
270
- > If you skipped the closeout commit, commit the staged changes first to
271
- > finish the Local-closeout gate. Then integration — merge / push / PR —
272
- > follows the selected Git policy, at your discretion."
318
+ > directory is now at `dflow/specs/features/completed/{SPEC-ID}-{slug}/`,
319
+ > with the Local-closeout gate satisfied (closeout committed and verified).
320
+ > Integration — merge / push / PR — follows the selected Git policy, at
321
+ > your discretion."
322
+
323
+ **In-flight reminder** — after the closeout announcement (with or without
324
+ Step 6), run the in-flight overview scan (see `AI-AGENT-GUIDE.md` § Status /
325
+ Control Commands) and list any other unfinished features in `active/` and any
326
+ in-flight feature / bugfix branches. Surfacing them at closeout is deliberate:
327
+ attention is about to move elsewhere, and this is exactly where half-done work
328
+ sinks.
273
329
 
274
330
  ## Step 6: Reverse-Update Follow-up Tracking (only if follow-up)
275
331