dflow-sdd-ddd 0.13.0 → 0.15.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 (96) hide show
  1. package/CHANGELOG.md +824 -1
  2. package/CONTRIBUTING.md +16 -10
  3. package/README.en.md +156 -200
  4. package/README.md +89 -144
  5. package/TEMPLATE-COVERAGE.md +15 -8
  6. package/TEMPLATE-LANGUAGE-GLOSSARY.md +15 -1
  7. package/bin/dflow.js +36 -4
  8. package/docs/commands.en.md +110 -0
  9. package/docs/commands.md +101 -0
  10. package/docs/doctor-uncertainty.en.md +212 -0
  11. package/docs/doctor-uncertainty.md +212 -0
  12. package/docs/evaluating-dflow.en.md +29 -11
  13. package/docs/evaluating-dflow.md +8 -6
  14. package/docs/npm-publish-checklist.md +3 -1
  15. package/docs/release-versioning-policy.md +8 -2
  16. package/docs/upgrading.en.md +196 -0
  17. package/docs/upgrading.md +197 -0
  18. package/docs/using-with-claude-code.en.md +25 -10
  19. package/docs/using-with-claude-code.md +20 -7
  20. package/docs/using-with-codex.en.md +18 -6
  21. package/docs/using-with-codex.md +16 -5
  22. package/docs/using-with-github-copilot.en.md +25 -10
  23. package/docs/using-with-github-copilot.md +21 -8
  24. package/lib/doc-shapes.json +997 -0
  25. package/lib/doctor-checks.js +2654 -0
  26. package/lib/init.js +3583 -107
  27. package/lib/render-diagrams.js +1474 -0
  28. package/lib/render.js +865 -49
  29. package/package.json +2 -2
  30. package/templates/brownfield/references/drift-verification.md +4 -0
  31. package/templates/brownfield/references/finish-feature-flow.md +635 -88
  32. package/templates/brownfield/references/finish-feature-follow-up.md +60 -0
  33. package/templates/brownfield/references/finish-feature-minimal-host.md +406 -0
  34. package/templates/brownfield/references/finish-feature-post-hoc-hotfix.md +95 -0
  35. package/templates/brownfield/references/git-integration.md +160 -15
  36. package/templates/brownfield/references/init-project-flow.md +26 -4
  37. package/templates/brownfield/references/modify-existing-flow.md +412 -87
  38. package/templates/brownfield/references/modify-existing-follow-up.md +121 -0
  39. package/templates/brownfield/references/modify-existing-post-hoc-hotfix.md +82 -0
  40. package/templates/brownfield/references/new-feature-flow.md +61 -6
  41. package/templates/brownfield/references/new-phase-flow.md +57 -7
  42. package/templates/brownfield/references/pr-review-checklist.md +303 -10
  43. package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +158 -34
  44. package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +1 -1
  45. package/templates/brownfield/scaffolding/Git-principles-gitflow.md +75 -6
  46. package/templates/brownfield/scaffolding/Git-principles-trunk.md +82 -8
  47. package/templates/brownfield/scaffolding/_conventions.md +50 -28
  48. package/templates/brownfield/scaffolding/_overview.md +1 -0
  49. package/templates/brownfield/templates/_index.md +151 -7
  50. package/templates/brownfield/templates/analysis.md +79 -0
  51. package/templates/brownfield/templates/behavior.md +1 -0
  52. package/templates/brownfield/templates/context-definition.md +1 -0
  53. package/templates/brownfield/templates/context-map.md +2 -1
  54. package/templates/brownfield/templates/glossary.md +1 -0
  55. package/templates/brownfield/templates/lightweight-spec.md +154 -11
  56. package/templates/brownfield/templates/models.md +1 -0
  57. package/templates/brownfield/templates/phase-spec.md +9 -1
  58. package/templates/brownfield/templates/rules.md +1 -0
  59. package/templates/brownfield/templates/tech-debt.md +1 -0
  60. package/templates/common/references/ddd-modeling-guide.md +33 -16
  61. package/templates/{greenfield → common}/references/dflow-feedback-flow.md +2 -1
  62. package/templates/common/references/flow-rationale-registry.md +130 -0
  63. package/templates/common/skill/SKILL.md +13 -11
  64. package/templates/greenfield/references/drift-verification.md +4 -0
  65. package/templates/greenfield/references/finish-feature-flow.md +625 -89
  66. package/templates/greenfield/references/finish-feature-follow-up.md +60 -0
  67. package/templates/greenfield/references/finish-feature-minimal-host.md +363 -0
  68. package/templates/greenfield/references/finish-feature-post-hoc-hotfix.md +95 -0
  69. package/templates/greenfield/references/git-integration.md +148 -15
  70. package/templates/greenfield/references/init-project-flow.md +28 -8
  71. package/templates/greenfield/references/modify-existing-flow.md +378 -85
  72. package/templates/greenfield/references/modify-existing-follow-up.md +103 -0
  73. package/templates/greenfield/references/modify-existing-post-hoc-hotfix.md +82 -0
  74. package/templates/greenfield/references/new-feature-flow.md +67 -4
  75. package/templates/greenfield/references/new-phase-flow.md +56 -7
  76. package/templates/greenfield/references/pr-review-checklist.md +287 -8
  77. package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +153 -32
  78. package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +5 -2
  79. package/templates/greenfield/scaffolding/Git-principles-gitflow.md +74 -6
  80. package/templates/greenfield/scaffolding/Git-principles-trunk.md +88 -12
  81. package/templates/greenfield/scaffolding/_conventions.md +50 -28
  82. package/templates/greenfield/scaffolding/_overview.md +6 -2
  83. package/templates/greenfield/templates/_index.md +137 -7
  84. package/templates/greenfield/templates/aggregate-design.md +1 -0
  85. package/templates/greenfield/templates/analysis.md +79 -0
  86. package/templates/greenfield/templates/behavior.md +1 -0
  87. package/templates/greenfield/templates/context-definition.md +1 -0
  88. package/templates/greenfield/templates/context-map.md +2 -1
  89. package/templates/greenfield/templates/events.md +4 -1
  90. package/templates/greenfield/templates/glossary.md +1 -0
  91. package/templates/greenfield/templates/lightweight-spec.md +154 -11
  92. package/templates/greenfield/templates/models.md +1 -0
  93. package/templates/greenfield/templates/phase-spec.md +9 -1
  94. package/templates/greenfield/templates/rules.md +1 -0
  95. package/templates/greenfield/templates/tech-debt.md +1 -0
  96. package/templates/brownfield/references/dflow-feedback-flow.md +0 -251
package/lib/init.js CHANGED
@@ -4,6 +4,7 @@ const readline = require('node:readline');
4
4
  const { TextDecoder } = require('node:util');
5
5
 
6
6
  const pkg = require('../package.json');
7
+ const doctorChecks = require('./doctor-checks');
7
8
 
8
9
  const MIN_NODE_VERSION = '22.0.0';
9
10
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
@@ -23,6 +24,33 @@ const CODEX_TRIGGER_SECTION_END = '<!-- dflow-generated: codex-command-triggers
23
24
  // freeze the pre-marker shim template for back-compat.
24
25
  const AGENT_SHIM_SECTION_START = '<!-- dflow-generated: agent-shim START -->';
25
26
  const AGENT_SHIM_SECTION_END = '<!-- dflow-generated: agent-shim END -->';
27
+ // PROPOSAL-058: the packaged guide templates wrap their Dflow-canonical body in
28
+ // this pair; configure-agents refreshes the region in place on upgrade and never
29
+ // touches content outside it ("## Project Context" and anything else the user
30
+ // keeps there).
31
+ const GUIDE_CANONICAL_SECTION_START = '<!-- dflow-generated: guide-canonical START -->';
32
+ const GUIDE_CANONICAL_SECTION_END = '<!-- dflow-generated: guide-canonical END -->';
33
+ const AI_AGENT_GUIDE_DEST = 'dflow/specs/shared/AI-AGENT-GUIDE.md';
34
+ // PROPOSAL-090 (route B3): the Git principles starter is user-owned — projects
35
+ // write their own AI-collaboration policy and CI/CD sections into it — but
36
+ // sections 1-5 are Dflow-canonical and three trunk flow files read them, so a
37
+ // starter frozen at its init version leaves those flows pointing at rules the
38
+ // project never received. Measured 2026-08-18 on a project whose upgrade
39
+ // process was otherwise flawless: 65 lines behind, and not polish.
40
+ // ⚠ A SEPARATE marker pair from the guide's, deliberately: the guide-canonical
41
+ // START string is also used as a section-boundary terminator in
42
+ // `projectContextSectionBounds`, so sharing the token would tie two unrelated
43
+ // mechanisms to one string.
44
+ const GIT_PRINCIPLES_CANONICAL_START = '<!-- dflow-generated: git-principles-canonical START -->';
45
+ const GIT_PRINCIPLES_CANONICAL_END = '<!-- dflow-generated: git-principles-canonical END -->';
46
+ // The two headings that bound the canonical region. Verified stable across
47
+ // v0.9.0..HEAD in all four starters (2 editions x 2 policies), which is what
48
+ // makes marker adoption on a pre-marker file decidable rather than a guess.
49
+ // ⚠ Anchor on the exact heading text, never on "the next `## `": the gitflow
50
+ // starters carry a `## [1.2.3] — {YYYY-MM-DD}` CHANGELOG example inside a
51
+ // fenced block.
52
+ const GIT_PRINCIPLES_CANONICAL_FIRST_HEADING = '## 1. Branch Structure';
53
+ const GIT_PRINCIPLES_CANONICAL_AFTER_HEADING = '## 6. AI Collaboration Rules (Project Policy)';
26
54
  const WORKFLOW_BUNDLE_DEST = 'dflow/specs/shared/dflow-workflows';
27
55
  const WORKFLOW_BUNDLE_MANIFEST_PATH = `${WORKFLOW_BUNDLE_DEST}/.dflow-bundle-manifest.json`;
28
56
  const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
@@ -32,7 +60,14 @@ const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
32
60
  // and configure-agents stale-removal would then DELETE the already-installed
33
61
  // copy from the user's project (it diffs as "retired"). PROPOSAL-064 fresh-gate
34
62
  // finding. Guarded before any stale cleanup / manifest write.
35
- const REQUIRED_COMMON_BUNDLE_FILES = ['references/ddd-modeling-guide.md'];
63
+ // Frozen because it is exported: an unfrozen array could be emptied by any
64
+ // consumer (`REQUIRED_COMMON_BUNDLE_FILES.length = 0`), silently turning the
65
+ // completeness guard into a no-op for the rest of the process.
66
+ const REQUIRED_COMMON_BUNDLE_FILES = Object.freeze([
67
+ 'references/ddd-modeling-guide.md',
68
+ 'references/dflow-feedback-flow.md',
69
+ 'references/flow-rationale-registry.md'
70
+ ]);
36
71
  const EXPECTED_COMMAND_IDS = [
37
72
  'new-feature',
38
73
  'modify-existing',
@@ -197,6 +232,31 @@ const DEFERRED_COMMON = [
197
232
  relativePath: 'dflow/specs/domain/{context}/rules.md',
198
233
  reason: 'Needs a real bounded context.'
199
234
  },
235
+ {
236
+ relativePath: 'dflow/specs/domain/{context}/analysis.md',
237
+ reason: 'Needs a real bounded context.'
238
+ },
239
+ {
240
+ relativePath: 'dflow/specs/domain/analysis.md',
241
+ reason: 'Created the first time a session records a cross-context flow, role reach, or other knowledge no single context owns.'
242
+ }
243
+ ];
244
+
245
+ // ⚠ EDITION-SPECIFIC DEFERRALS, and the reason this list exists is that the
246
+ // other one is named COMMON. The ADR row used to sit in DEFERRED_COMMON while
247
+ // `dflow/specs/architecture/` is a greenfield-only tree — brownfield records
248
+ // architecture decisions under `dflow/specs/migration/` and never creates
249
+ // `architecture/` at all. So every brownfield `dflow init` printed a promise to
250
+ // create a directory the CLI will never create (`debt20-tut-y5`, confirmed by
251
+ // `debt20-tut-y6`). `buildDeferredItems` only ever ADDED to the common list, so
252
+ // nothing could subtract a row that did not apply — the fix is to stop putting
253
+ // edition-specific rows in the shared list rather than to add a subtraction
254
+ // step. Anything that is true for one edition only belongs here.
255
+ const DEFERRED_GREENFIELD_ONLY = [
256
+ {
257
+ relativePath: 'dflow/specs/domain/{context}/events.md',
258
+ reason: 'Greenfield only, but still needs a real bounded context.'
259
+ },
200
260
  {
201
261
  relativePath: 'dflow/specs/architecture/decisions/ADR-*.md',
202
262
  reason: 'ADRs are created when a real architecture decision exists.'
@@ -367,14 +427,52 @@ async function runConfigureAgents(options = {}) {
367
427
  }
368
428
  }
369
429
 
370
- const plan = await buildConfigureAgentsPlan(cwd, {
430
+ // PROPOSAL-058: adoption offers are decided by planning the run twice. The
431
+ // first plan flags what is offer-able (a recognizable pre-marker guide, a
432
+ // guide-referencing agent file Dflow does not manage); an interactive run
433
+ // then asks, and only a granted consent triggers a re-plan. A non-TTY run
434
+ // never asks and never consumes a stdin slot (the PROPOSAL-074 contract:
435
+ // existing piped answer sequences must run unchanged), so it keeps the
436
+ // skip + warn behavior. Deriving the offers from the plan itself keeps the
437
+ // question conditions and the plan branches from ever drifting apart.
438
+ const interactive = Boolean(stdin.isTTY && stdout.isTTY);
439
+ let adoptGuideMarkers = false;
440
+ let adoptGitPrinciplesMarkers = false;
441
+ const adoptShimAgents = [];
442
+ const buildPlan = () => buildConfigureAgentsPlan(cwd, {
371
443
  ...projectContext,
372
444
  aiAgents,
373
445
  commandAdapters: Boolean(options.commandAdapters),
374
446
  skills: Boolean(options.skills),
375
- skillAgents
447
+ skillAgents,
448
+ adoptGuideMarkers,
449
+ adoptGitPrinciplesMarkers,
450
+ adoptShimAgents
376
451
  });
377
452
 
453
+ let plan = await buildPlan();
454
+
455
+ if (interactive) {
456
+ const offersGuide = plan.items.some((item) => item.offerGuideAdoption);
457
+ const shimOffers = dedupe(
458
+ plan.items.filter((item) => item.offerShimAdoption).map((item) => item.offerShimAdoption)
459
+ );
460
+ if (offersGuide) {
461
+ adoptGuideMarkers = await askGuideMarkerAdoption(rl, stdout);
462
+ }
463
+ if (plan.items.some((item) => item.offerGitPrinciplesAdoption)) {
464
+ adoptGitPrinciplesMarkers = await askGitPrinciplesMarkerAdoption(rl, stdout);
465
+ }
466
+ for (const agent of shimOffers) {
467
+ if (await askShimBlockAdoption(rl, stdout, agent)) {
468
+ adoptShimAgents.push(agent);
469
+ }
470
+ }
471
+ if (adoptGuideMarkers || adoptGitPrinciplesMarkers || adoptShimAgents.length > 0) {
472
+ plan = await buildPlan();
473
+ }
474
+ }
475
+
378
476
  const warnings = plan.warnings || [];
379
477
  renderPreview(stdout, plan, warnings);
380
478
  const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
@@ -752,18 +850,19 @@ async function inferProjectContext(cwd, rl, stdout, stderr) {
752
850
  };
753
851
  }
754
852
 
853
+ // The machine-readable line patterns and value parse live in lib/doctor-checks.js
854
+ // so the doctor "machine format" findings and this inference can never drift
855
+ // apart (PROPOSAL-058).
755
856
  async function inferGitPolicy(cwd) {
756
857
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
757
858
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
758
- const match = content.match(/Selected Git policy:\s*`([^`]+)`/);
759
- return match ? match[1] : null;
859
+ return doctorChecks.parseContextLine(content, doctorChecks.GIT_POLICY_LINE_RE);
760
860
  }
761
861
 
762
862
  async function inferAiCommitMarker(cwd) {
763
863
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
764
864
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
765
- const match = content.match(/AI commit marker:\s*`([^`]+)`/);
766
- return match ? match[1] : null;
865
+ return doctorChecks.parseContextLine(content, doctorChecks.AI_COMMIT_MARKER_LINE_RE);
767
866
  }
768
867
 
769
868
  async function inferExistingEdition(cwd) {
@@ -782,22 +881,43 @@ async function inferExistingEdition(cwd) {
782
881
  async function inferProseLanguage(cwd) {
783
882
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
784
883
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
785
- const match = content.match(/Project prose language:\s*`([^`]+)`/);
786
- return match ? match[1] : 'unknown';
884
+ return doctorChecks.parseContextLine(content, doctorChecks.PROSE_LANGUAGE_LINE_RE) ?? 'unknown';
787
885
  }
788
886
 
887
+ // PROPOSAL-076: the machine-readable record of the init Q2/Q3 answers is the
888
+ // guide's "## Project Context" table — init substitutes them into the
889
+ // `| Tech stack |` / `| Migration / legacy context |` rows there. (The
890
+ // pre-076 code looked for those rows in _overview.md, which never carried
891
+ // them in any packaged template, so inference always fell back.) Only the
892
+ // Project Context section is parsed: it is the contractual user region
893
+ // (PROPOSAL-058), so a same-name row anywhere else in the guide can never
894
+ // shadow it.
789
895
  async function inferTechStackSummary(cwd) {
790
- const overviewPath = path.join(cwd, 'dflow/specs/shared/_overview.md');
791
- const content = await fs.readFile(overviewPath, 'utf8').catch(() => '');
792
- const match = content.match(/\|\s*Tech stack\s*\|\s*([^|\n]+?)\s*\|/i);
793
- return match ? match[1].trim() : 'unknown';
896
+ return (await inferGuideProjectContextValue(cwd, doctorChecks.TECH_STACK_ROW_RE)) ?? 'unknown';
794
897
  }
795
898
 
796
899
  async function inferMigrationContext(cwd) {
797
- const overviewPath = path.join(cwd, 'dflow/specs/shared/_overview.md');
798
- const content = await fs.readFile(overviewPath, 'utf8').catch(() => '');
799
- const match = content.match(/\|\s*Migration \/ legacy context\s*\|\s*([^|\n]+?)\s*\|/i);
800
- return match ? match[1].trim() : 'none';
900
+ return (await inferGuideProjectContextValue(cwd, doctorChecks.MIGRATION_CONTEXT_ROW_RE)) ?? 'none';
901
+ }
902
+
903
+ async function inferGuideProjectContextValue(cwd, re) {
904
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
905
+ const content = await fs.readFile(guidePath, 'utf8').catch(() => '');
906
+ const slice = projectContextParseSlice(toLf(content));
907
+ return slice === null ? null : doctorChecks.parseContextLine(slice, re);
908
+ }
909
+
910
+ // The parseable slice of the "## Project Context" section: fenced examples
911
+ // inside the section are blanked so a decoy row in a fence can neither supply
912
+ // nor shadow a value (PROPOSAL-076 gate G2) — fence state is clean at the
913
+ // heading because the fence-aware bounds scan would not have matched a heading
914
+ // inside a fence. Inference and the doctor row check must both parse through
915
+ // this slice so they can never disagree.
916
+ function projectContextParseSlice(lfContent) {
917
+ const bounds = projectContextSectionBounds(lfContent);
918
+ if (!bounds) return null;
919
+ const section = bounds.lines.slice(bounds.start, bounds.end).join('\n');
920
+ return doctorChecks.blankFencedBlocks(section).join('\n');
801
921
  }
802
922
 
803
923
  async function askSelect(rl, stdout, stderr, config) {
@@ -1221,19 +1341,19 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1221
1341
  const items = [];
1222
1342
  const warnings = [];
1223
1343
 
1224
- let content = await readPackagedTemplate(answers.edition, 'scaffolding/AI-AGENT-GUIDE.md');
1225
- content = substitutePlaceholders(content, substitution);
1226
- items.push({
1227
- relativePath: 'dflow/specs/shared/AI-AGENT-GUIDE.md',
1228
- source: `packaged:${answers.edition}/scaffolding/AI-AGENT-GUIDE.md`,
1229
- notes: 'canonical AI agent guide',
1230
- content
1231
- });
1344
+ let packagedGuide = await readPackagedTemplate(answers.edition, 'scaffolding/AI-AGENT-GUIDE.md');
1345
+ packagedGuide = substitutePlaceholders(packagedGuide, substitution);
1346
+ await addCanonicalGuideItem(cwd, items, warnings, packagedGuide, answers);
1347
+ await addGitPrinciplesItem(cwd, items, warnings, answers);
1232
1348
 
1233
- const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(content) : [];
1349
+ const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(packagedGuide) : [];
1234
1350
 
1235
1351
  for (const agent of answers.aiAgents) {
1236
- await addAiAgentShim(cwd, items, agent, substitution, { commandRegistry, warnings });
1352
+ await addAiAgentShim(cwd, items, agent, substitution, {
1353
+ commandRegistry,
1354
+ warnings,
1355
+ adoptShimAgents: answers.adoptShimAgents || []
1356
+ });
1237
1357
  }
1238
1358
 
1239
1359
  if (answers.commandAdapters) {
@@ -1260,6 +1380,15 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1260
1380
  await addWorkflowBundleItems(cwd, items, bundleWarnings, answers.edition);
1261
1381
  warnings.push(...bundleWarnings);
1262
1382
 
1383
+ // Last plan item on purpose: the write phase runs in plan order and aborts on
1384
+ // the first failure, so the last-reconciled version line only advances when
1385
+ // everything this run re-projects was written. Guarded skips do not abort the
1386
+ // write phase, so the item additionally carries requiresFullApply — the write
1387
+ // phase drops it (with a warning) when any earlier planned change was skipped
1388
+ // unexpectedly (changed after preview, vanished target, unexpected existing
1389
+ // target).
1390
+ await addConventionsVersionReconcileItem(cwd, items);
1391
+
1263
1392
  return {
1264
1393
  items,
1265
1394
  deferred: [],
@@ -1296,15 +1425,65 @@ async function finalizePlanItems(cwd, items) {
1296
1425
  // source trees, so the merged dest path / manifest entry never collide or
1297
1426
  // shadow each other. Pure (no I/O) so it is unit-testable on a synthetic list.
1298
1427
  function assertNoBundleCollision(files) {
1428
+ // Keyed case-INSENSITIVELY. The merged set is projected onto the adopter's
1429
+ // filesystem, where Windows and default macOS treat `Foo.md` and `foo.md` as
1430
+ // one path: two source trees differing only in case would ship two logical
1431
+ // files that overwrite each other there, while an exact-match key reports no
1432
+ // collision at all. A case-sensitive dev checkout or CI can create that pair,
1433
+ // so the guard cannot rely on the authoring filesystem to prevent it.
1434
+ // Case-folded and NFC-normalised. ⚠ The guard's two halves have DIFFERENT
1435
+ // reach, and collapsing them into one sentence has produced a wrong comment
1436
+ // twice — once claiming adopters are protected everywhere, once claiming the
1437
+ // guard is silent everywhere. Both were generalised from a probe that only
1438
+ // covered one half. Measured on a case-insensitive (Windows) host:
1439
+ //
1440
+ // * CROSS-ROOT pair (templates/common/… vs templates/{edition}/…) — the two
1441
+ // files sit in DIFFERENT directories, so both spellings exist on any
1442
+ // filesystem. readdir yields a descriptor from each root and this guard
1443
+ // throws before anything is projected. Probe: common/x.md + greenfield/X.md
1444
+ // both present, guard fires. Real runtime protection, Windows included,
1445
+ // and the case this guard mainly exists for.
1446
+ // * SAME-TREE pair (two spellings in ONE directory) — reach depends on WHICH
1447
+ // axis differs, because the key folds two of them and filesystems do not
1448
+ // treat them alike:
1449
+ //
1450
+ // axis \ host | Linux (case-sens, norm-sens) | Windows (case-INsens, norm-SENS) | macOS default (both INsens)
1451
+ // case pair | coexists -> canary | collapses -> silent | collapses -> silent
1452
+ // NFD/NFC pair | coexists -> canary | COEXISTS -> guard fires | collapses -> silent
1453
+ //
1454
+ // Probes on Windows: `Foo.md` then `foo.md` leaves readdir count=1 (and
1455
+ // the FIRST name survives carrying the SECOND write's content); an
1456
+ // NFD/NFC pair leaves count=2 and this guard throws. NTFS is
1457
+ // case-insensitive but normalization-SENSITIVE — so the normalisation is
1458
+ // not defensive dressing, it is the one axis with runtime reach here.
1459
+ // ⚠ The macOS column is inference from documented APFS/HFS+ behaviour,
1460
+ // not measured; the other two columns are measured on this host.
1461
+ //
1462
+ // Guarding the source filenames themselves — OS-special forms such as a
1463
+ // Windows ADS `name.md:stream` — is a wider job, filed as
1464
+ // `bundle-name-validity` in planning/opt-in-backlog.md.
1465
+ //
1466
+ // The real key set is ASCII today, which is exactly why the normalisation has
1467
+ // to be written here rather than assumed.
1299
1468
  const seenBy = new Map();
1300
1469
  for (const f of files) {
1301
- const prior = seenBy.get(f.sourceRel);
1302
- if (prior && prior !== f.sourceRoot) {
1470
+ const key = f.sourceRel.normalize('NFC').toLowerCase();
1471
+ const prior = seenBy.get(key);
1472
+ // Two spellings collide REGARDLESS of which tree they came from. Keying on
1473
+ // sourceRoot alone would miss the same hazard inside one tree — two files
1474
+ // in templates/common/ differing only in case are equally one path on the
1475
+ // adopter's filesystem, and would project two manifest entries onto it.
1476
+ if (prior && (prior.sourceRoot !== f.sourceRoot || prior.sourceRel !== f.sourceRel)) {
1477
+ const where = prior.sourceRoot === f.sourceRoot
1478
+ ? `twice in templates/${f.sourceRoot}/ (as "${prior.sourceRel}" and "${f.sourceRel}")`
1479
+ : prior.sourceRel === f.sourceRel
1480
+ ? `in both templates/${prior.sourceRoot}/ and templates/${f.sourceRoot}/`
1481
+ : `in both templates/${prior.sourceRoot}/ (as "${prior.sourceRel}") and templates/${f.sourceRoot}/ (as "${f.sourceRel}")`;
1303
1482
  throw new InitError(
1304
- `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.`
1483
+ `Internal error: workflow bundle file "${f.sourceRel}" exists ${where}. A bundle file name must be unique across the common and edition source trees — compared case-insensitively, because the projected destination lands on filesystems that treat "Foo.md" and "foo.md" as one path.`
1305
1484
  );
1306
1485
  }
1307
- seenBy.set(f.sourceRel, f.sourceRoot);
1486
+ seenBy.set(key, f);
1308
1487
  }
1309
1488
  }
1310
1489
 
@@ -1313,10 +1492,14 @@ function assertNoBundleCollision(files) {
1313
1492
  // (templates/) — the common tree must NOT mask a broken edition (a vanished
1314
1493
  // templates/{edition}/references/ would otherwise be hidden by common's
1315
1494
  // non-empty references/). Enforced in the scanner (not only the projector) so
1316
- // BOTH callers are covered: the projector hard-fails, and doctor's existing
1317
- // try/catch around listBundleSourceFiles degrades to skipping the orphan scan
1318
- // rather than mis-reporting every projected flow file as orphaned. Pure (no
1319
- // I/O) so it is unit-testable on a synthetic list.
1495
+ // BOTH callers are covered: the projector hard-fails, and doctor skips the orphan
1496
+ // scan rather than mis-reporting every projected flow file as orphaned.
1497
+ // ⚠ That skip is no longer SILENT, and this sentence used to read as permission for
1498
+ // it to be: doctor now also emits a `warn` naming the broken install
1499
+ // (see checkWorkflowBundleSourceAndOrphans). A maintainer who restores a bare
1500
+ // `catch { return; }` on the strength of "degrades gracefully" reintroduces the
1501
+ // exact false-clean this repo spent four review rounds closing. Pure (no I/O) so it
1502
+ // is unit-testable on a synthetic list.
1320
1503
  function assertEditionBundleComplete(files, edition) {
1321
1504
  const hasEditionRefs = files.some((f) => f.sourceRoot === edition && f.dir === 'references');
1322
1505
  const hasEditionTemplates = files.some((f) => f.sourceRoot === edition && f.dir === 'templates');
@@ -1353,32 +1536,38 @@ function assertCommonBundleComplete(files) {
1353
1536
  // at the same dflow/.../references/<name> path in every edition. The scanner
1354
1537
  // validates the merged set (collision + complete-edition guards) before
1355
1538
  // returning, so both callers (projector, doctor) get a trustworthy list.
1356
- async function listBundleSourceFiles(edition) {
1357
- const bundleDirs = ['references', 'templates'];
1358
- const sourceRoots = ['common', edition];
1539
+ async function scanBundleSourceRoot(sourceRoot) {
1359
1540
  const files = [];
1360
-
1361
- for (const sourceRoot of sourceRoots) {
1362
- for (const dir of bundleDirs) {
1363
- const sourceDir = path.join(TEMPLATE_ROOT, sourceRoot, dir);
1364
- let entries;
1365
- try {
1366
- entries = await fs.readdir(sourceDir);
1367
- } catch (error) {
1368
- if (error.code === 'ENOENT') {
1369
- continue;
1370
- }
1371
- throw error;
1541
+ for (const dir of ['references', 'templates']) {
1542
+ const sourceDir = path.join(TEMPLATE_ROOT, sourceRoot, dir);
1543
+ let entries;
1544
+ try {
1545
+ entries = await fs.readdir(sourceDir);
1546
+ } catch (error) {
1547
+ if (error.code === 'ENOENT') {
1548
+ continue;
1372
1549
  }
1373
- for (const entry of entries) {
1374
- const sourcePath = path.join(sourceDir, entry);
1375
- const stat = await fs.stat(sourcePath);
1376
- if (stat.isFile()) {
1377
- files.push({ sourceRel: `${dir}/${entry}`, dir, name: entry, sourceRoot });
1378
- }
1550
+ throw error;
1551
+ }
1552
+ for (const entry of entries) {
1553
+ const sourcePath = path.join(sourceDir, entry);
1554
+ const stat = await fs.stat(sourcePath);
1555
+ if (stat.isFile()) {
1556
+ files.push({ sourceRel: `${dir}/${entry}`, dir, name: entry, sourceRoot });
1379
1557
  }
1380
1558
  }
1381
1559
  }
1560
+ return files;
1561
+ }
1562
+
1563
+ async function listBundleSourceFiles(edition) {
1564
+ // Order is preserved from when this was one nested loop over
1565
+ // ['common', edition] x ['references', 'templates'] — the extraction is
1566
+ // mechanical, and callers that key on array order see no change.
1567
+ const files = [
1568
+ ...(await scanBundleSourceRoot('common')),
1569
+ ...(await scanBundleSourceRoot(edition))
1570
+ ];
1382
1571
 
1383
1572
  assertNoBundleCollision(files);
1384
1573
  assertEditionBundleComplete(files, edition);
@@ -1386,6 +1575,8 @@ async function listBundleSourceFiles(edition) {
1386
1575
  return files;
1387
1576
  }
1388
1577
 
1578
+ const BUNDLE_EDITIONS = Object.freeze(['greenfield', 'brownfield']);
1579
+
1389
1580
  // Reads the per-project workflow bundle manifest, distinguishing "absent"
1390
1581
  // (normal: fresh init / first projection) from "corrupt" (unreadable or invalid
1391
1582
  // shape). A corrupt manifest must NOT be treated as absent: that would silently
@@ -1623,6 +1814,483 @@ async function readPackagedBundleFile(sourceRoot, sourceRel) {
1623
1814
  }
1624
1815
  }
1625
1816
 
1817
+ // PROPOSAL-090 route B3 — refresh the Dflow-canonical half of the Git
1818
+ // principles starter. Same decision table as the guide above (user decision
1819
+ // 2026-06-08: skip + warn + offer; never rewrite unasked):
1820
+ // - well-formed markers -> replace the marked region in place
1821
+ // - no markers, recognizable -> skip + warn, flag for interactive adoption
1822
+ // - no markers, unrecognizable -> skip + warn
1823
+ // - malformed marker pair -> skip + warn (never guess)
1824
+ //
1825
+ // ⚠ The region is sections 1-5 only. Sections 6+ (AI Collaboration Rules,
1826
+ // CI / CD) are project policy, and the file header carries a project-filled
1827
+ // `> Created: {YYYY-MM-DD}` — both stay outside. Known cost, accepted when the
1828
+ // route was chosen: canonical material that sits BELOW section 6 keeps drifting
1829
+ // (`## Related Documents` in all four; `## 7. Hotfixes under Trunk-based` in the
1830
+ // greenfield trunk starter, which is a drift that has actually happened).
1831
+ async function addGitPrinciplesItem(cwd, items, warnings, answers) {
1832
+ const policy = answers.gitPolicy === 'gitflow' || answers.gitPolicy === 'trunk' ? answers.gitPolicy : null;
1833
+ if (!policy) {
1834
+ // ⚠ Say so rather than doing nothing. The policy comes from
1835
+ // `_conventions.md` § Git Policy, and a missing section silently disables
1836
+ // every downstream consumer — the defect PROPOSAL-091 exists to fix. A
1837
+ // silent no-op here would add one more.
1838
+ warnings.push(
1839
+ 'Could not determine the selected Git policy from `dflow/specs/shared/_conventions.md`, so `Git-principles-*.md` was not refreshed. Restore its `## Git Policy` section (`Selected Git policy: \`gitflow\`` or `\`trunk\``) and re-run.'
1840
+ );
1841
+ return;
1842
+ }
1843
+
1844
+ const relativePath = `dflow/specs/shared/Git-principles-${policy}.md`;
1845
+ const source = `packaged:${answers.edition}/scaffolding/Git-principles-${policy}.md`;
1846
+ const absolute = path.join(cwd, relativePath);
1847
+
1848
+ let packaged = null;
1849
+ let packagedError = null;
1850
+ try {
1851
+ packaged = await readPackagedTemplate(answers.edition, `scaffolding/Git-principles-${policy}.md`);
1852
+ } catch (error) {
1853
+ packagedError = error;
1854
+ }
1855
+ // ⚠⚠ AN UNREADABLE PACKAGED STARTER IS A BROKEN INSTALL, NOT A NO-OP. This
1856
+ // used to be `catch { packaged = null; }` followed by a silent `return`, and
1857
+ // `p090-b3-y1` executed what that bought: with the packaged starter deleted,
1858
+ // `doctor` printed "`dflow configure-agents` fails on this package before
1859
+ // writing a byte" while `configure-agents` exited 0, said nothing about Git
1860
+ // principles, left stale canonical content in place, and still advanced the
1861
+ // `> Dflow Version:` reconciliation line. The doctor sentence was a FALSE
1862
+ // CLAIM ABOUT ANOTHER COMMAND — the precise failure this whole line of work
1863
+ // exists to remove, committed while removing it.
1864
+ // ⚠ It throws rather than warns, for the same reason the malformed-marker
1865
+ // case below does: both mean the shipped package cannot answer what this
1866
+ // function is for, and the four starters all ship, so neither state is
1867
+ // reachable on a healthy install. Failing loudly keeps the two commands
1868
+ // saying the same thing about one package.
1869
+ if (packaged === null) {
1870
+ throw new InitError(
1871
+ `Internal error: packaged Git-principles-${policy}.md could not be read (${packagedError && packagedError.message ? packagedError.message : packagedError}).`
1872
+ );
1873
+ }
1874
+
1875
+ const packagedRegion = classifyMarkedRegion(
1876
+ packaged,
1877
+ GIT_PRINCIPLES_CANONICAL_START,
1878
+ GIT_PRINCIPLES_CANONICAL_END
1879
+ );
1880
+ if (packagedRegion.state !== 'present') {
1881
+ throw new InitError(
1882
+ `Internal error: packaged Git-principles-${policy}.md has no well-formed git-principles-canonical markers.`
1883
+ );
1884
+ }
1885
+
1886
+ if (!(await pathExists(absolute))) {
1887
+ // Seeding is `dflow init`'s job (it picks the policy). If the file is gone,
1888
+ // doctor reports it; configure-agents does not silently re-create it,
1889
+ // because a project that deleted it may have meant to.
1890
+ return;
1891
+ }
1892
+
1893
+ const existingContent = await fs.readFile(absolute, 'utf8');
1894
+ const eol = detectDominantEol(existingContent);
1895
+ const lf = toLf(existingContent);
1896
+ const region = classifyMarkedRegion(lf, GIT_PRINCIPLES_CANONICAL_START, GIT_PRINCIPLES_CANONICAL_END);
1897
+
1898
+ const skipItem = (notes) => {
1899
+ items.push({
1900
+ relativePath,
1901
+ source,
1902
+ notes,
1903
+ content: packaged,
1904
+ action: 'skip',
1905
+ intentionalSkip: true,
1906
+ size: Buffer.byteLength(packaged, 'utf8')
1907
+ });
1908
+ };
1909
+
1910
+ if (region.state === 'present') {
1911
+ const refreshed =
1912
+ lf.slice(0, region.startIdx) +
1913
+ packaged.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
1914
+ lf.slice(region.endIdx);
1915
+ if (refreshed === lf) {
1916
+ return;
1917
+ }
1918
+ pushRootInjectItem(items, {
1919
+ relativePath,
1920
+ source,
1921
+ notes: 'refreshed Dflow-canonical Git principles (sections 1-5; your own sections kept)',
1922
+ content: applyEol(refreshed, eol),
1923
+ expectedContent: existingContent
1924
+ });
1925
+ return;
1926
+ }
1927
+
1928
+ if (region.state === 'malformed') {
1929
+ warnings.push(
1930
+ `Existing ${relativePath} contains malformed git-principles-canonical markers; left it untouched. Repair or remove the stray markers and re-run so Dflow can refresh the canonical sections.`
1931
+ );
1932
+ skipItem('Git principles starter, malformed git-principles-canonical markers; left untouched');
1933
+ return;
1934
+ }
1935
+
1936
+ const bounds = gitPrinciplesCanonicalBounds(lf);
1937
+ if (bounds) {
1938
+ if (answers.adoptGitPrinciplesMarkers) {
1939
+ // ⚠ The separators are rebuilt explicitly, not inherited from either side.
1940
+ // `bounds` names the two heading lines and the packaged region is bounded
1941
+ // by the marker text, so a naive three-way concat glues
1942
+ // `<!-- ... END -->` onto `## 6.` and silently restructures a section this
1943
+ // path promises to keep. (Whole-file EOL is normalized to the dominant
1944
+ // ending either way — pre-existing shared behaviour, so the promise is
1945
+ // content preservation, not byte equality, on a mixed-ending file.)
1946
+ // Caught by review round
1947
+ // `p090-b3-x1` as a critical finding, with a fixture that reproduced it.
1948
+ const nl = String.fromCharCode(10);
1949
+ const adopted =
1950
+ lf.slice(0, bounds.start) +
1951
+ nl +
1952
+ packaged.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
1953
+ nl +
1954
+ nl +
1955
+ lf.slice(bounds.end);
1956
+ pushRootInjectItem(items, {
1957
+ relativePath,
1958
+ source,
1959
+ notes: 'adopted git-principles-canonical markers (kept your sections 6+ and the file header)',
1960
+ content: applyEol(adopted, eol),
1961
+ expectedContent: existingContent
1962
+ });
1963
+ return;
1964
+ }
1965
+ warnings.push(
1966
+ `${relativePath} predates Dflow's git-principles-canonical markers, so its canonical sections stay at the Dflow version that wrote them. Re-run \`dflow configure-agents\` on an interactive terminal and accept the marker-adoption offer, or reconcile manually against a fresh \`dflow init\`.`
1967
+ );
1968
+ skipItem('Git principles starter, no git-principles-canonical markers; left untouched');
1969
+ items[items.length - 1].offerGitPrinciplesAdoption = true;
1970
+ return;
1971
+ }
1972
+
1973
+ warnings.push(
1974
+ `Existing ${relativePath} is not recognizable as a Dflow Git principles starter (its \`${GIT_PRINCIPLES_CANONICAL_FIRST_HEADING}\` / \`${GIT_PRINCIPLES_CANONICAL_AFTER_HEADING}\` headings were not both found); left it untouched.`
1975
+ );
1976
+ skipItem('Git principles starter, not recognizable; left untouched');
1977
+ }
1978
+
1979
+ // The byte span sections 1-5 occupy in a pre-marker file: from the start of the
1980
+ // `## 1.` heading line to the start of the `## 6.` heading line.
1981
+ // ⚠ Search the MASK, slice the ORIGINAL — same rule as `classifyMarkedRegion`.
1982
+ // A heading shown inside a fenced example is documentation, not a boundary.
1983
+ // Returns null unless BOTH anchors are found exactly once, in order; anything
1984
+ // else is unrecognizable and must not be guessed at.
1985
+ function gitPrinciplesCanonicalBounds(lfContent) {
1986
+ const searchable = doctorChecks.maskCodeBlocks(lfContent);
1987
+ const nl = String.fromCharCode(10);
1988
+ const first = nl + GIT_PRINCIPLES_CANONICAL_FIRST_HEADING + nl;
1989
+ const after = nl + GIT_PRINCIPLES_CANONICAL_AFTER_HEADING + nl;
1990
+ if (countOccurrences(searchable, first) !== 1 || countOccurrences(searchable, after) !== 1) {
1991
+ return null;
1992
+ }
1993
+ // `start` lands ON the newline that precedes `## 1.` and `end` on the `#` of
1994
+ // `## 6.`, so the caller owns every separator between the three pieces it
1995
+ // joins. Making both point at the `#` is what produced the glued
1996
+ // `<!-- ... END -->## 6.` that review round `p090-b3-x1` reproduced.
1997
+ const start = searchable.indexOf(first);
1998
+ const end = searchable.indexOf(after) + 1;
1999
+ if (start >= end) {
2000
+ return null;
2001
+ }
2002
+ return { start, end };
2003
+ }
2004
+
2005
+ // PROPOSAL-058: the canonical AI agent guide is user-owned (it embeds the
2006
+ // project's "## Project Context"), but most of its body is Dflow-canonical
2007
+ // content that upgrades must be able to refresh — a guide frozen at its init
2008
+ // version leaves the re-projected workflow bundle § referencing sections the
2009
+ // guide does not have. The packaged template wraps the canonical body in
2010
+ // guide-canonical START/END markers. Decision table for an existing guide
2011
+ // (user decision 2026-06-08: skip + warn + offer; never rewrite unasked):
2012
+ // - well-formed markers -> replace the marked region in place (idempotent)
2013
+ // - no markers, recognizable -> skip + warn, and flag the item so an
2014
+ // interactive run can offer marker adoption. Adoption replaces everything
2015
+ // outside "## Project Context" with this version's canonical guide content;
2016
+ // it is consent-gated because historical canonical text cannot be verified
2017
+ // against the current package — only the user knows whether they customized
2018
+ // sections outside Project Context.
2019
+ // - no markers, unrecognizable -> skip + warn
2020
+ // - malformed marker pair -> skip + warn (never guess)
2021
+ async function addCanonicalGuideItem(cwd, items, warnings, packagedGuide, answers) {
2022
+ const source = `packaged:${answers.edition}/scaffolding/AI-AGENT-GUIDE.md`;
2023
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
2024
+
2025
+ if (!(await pathExists(guidePath))) {
2026
+ items.push({
2027
+ relativePath: AI_AGENT_GUIDE_DEST,
2028
+ source,
2029
+ notes: 'canonical AI agent guide',
2030
+ content: packagedGuide
2031
+ });
2032
+ return;
2033
+ }
2034
+
2035
+ const packagedRegion = classifyMarkedRegion(
2036
+ packagedGuide,
2037
+ GUIDE_CANONICAL_SECTION_START,
2038
+ GUIDE_CANONICAL_SECTION_END
2039
+ );
2040
+ if (packagedRegion.state !== 'present') {
2041
+ throw new InitError('Internal error: packaged AI-AGENT-GUIDE.md has no well-formed guide-canonical markers.');
2042
+ }
2043
+
2044
+ const existingContent = await fs.readFile(guidePath, 'utf8');
2045
+ const eol = detectDominantEol(existingContent);
2046
+ const lf = toLf(existingContent);
2047
+ const region = classifyMarkedRegion(lf, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
2048
+
2049
+ const skipItem = (notes) => {
2050
+ items.push({
2051
+ relativePath: AI_AGENT_GUIDE_DEST,
2052
+ source,
2053
+ notes,
2054
+ content: packagedGuide,
2055
+ action: 'skip',
2056
+ intentionalSkip: true,
2057
+ size: Buffer.byteLength(packagedGuide, 'utf8')
2058
+ });
2059
+ };
2060
+
2061
+ if (region.state === 'present') {
2062
+ const refreshed =
2063
+ lf.slice(0, region.startIdx) +
2064
+ packagedGuide.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
2065
+ lf.slice(region.endIdx);
2066
+ pushRootInjectItem(items, {
2067
+ relativePath: AI_AGENT_GUIDE_DEST,
2068
+ source,
2069
+ notes: 'refreshed Dflow-canonical guide content (content outside the markers kept)',
2070
+ content: applyEol(refreshed, eol),
2071
+ expectedContent: existingContent
2072
+ });
2073
+ return;
2074
+ }
2075
+
2076
+ if (region.state === 'malformed') {
2077
+ warnings.push(
2078
+ `Existing ${AI_AGENT_GUIDE_DEST} contains malformed guide-canonical markers; left it untouched. Repair or remove the stray markers and re-run so Dflow can refresh the canonical content.`
2079
+ );
2080
+ skipItem('canonical AI agent guide, malformed guide-canonical markers; left untouched');
2081
+ return;
2082
+ }
2083
+
2084
+ if (isRecognizableDflowGuide(lf)) {
2085
+ if (answers.adoptGuideMarkers) {
2086
+ pushRootInjectItem(items, {
2087
+ relativePath: AI_AGENT_GUIDE_DEST,
2088
+ source,
2089
+ notes: 'adopted guide-canonical markers (kept your "## Project Context" section)',
2090
+ content: applyEol(transplantProjectContext(packagedGuide, lf), eol),
2091
+ expectedContent: existingContent
2092
+ });
2093
+ return;
2094
+ }
2095
+ warnings.push(
2096
+ `${AI_AGENT_GUIDE_DEST} predates Dflow's guide-canonical markers, so its canonical sections stay at the Dflow version that wrote them. Re-run \`dflow configure-agents\` on an interactive terminal and accept the marker-adoption offer, or reconcile manually against a fresh \`dflow init\`.`
2097
+ );
2098
+ const item = 'canonical AI agent guide, no guide-canonical markers; left untouched';
2099
+ skipItem(item);
2100
+ items[items.length - 1].offerGuideAdoption = true;
2101
+ return;
2102
+ }
2103
+
2104
+ warnings.push(
2105
+ `Existing ${AI_AGENT_GUIDE_DEST} is not recognizable as a Dflow guide; left it untouched.`
2106
+ );
2107
+ skipItem('canonical AI agent guide, not recognizable as a Dflow guide; left untouched');
2108
+ }
2109
+
2110
+ // Fence-aware, and deliberately the same predicate transplantProjectContext
2111
+ // relies on: recognizability must imply locatable Project Context bounds, or
2112
+ // an accepted adoption offer would abort on the internal error below
2113
+ // (PROPOSAL-076 gate G3 — a guide whose only "## Project Context" heading sat
2114
+ // inside a fenced example was offered adoption and then crashed the run).
2115
+ // \u26A0 The title test compares the heading TEXT the shared classification produces,
2116
+ // rather than matching the raw line. That closes the last hand-rolled heading
2117
+ // rule outside `doctor-checks.js`, and it moves the behaviour twice, in opposite
2118
+ // directions \u2014 both deliberate, both pinned:
2119
+ // - WIDER: a 0-3 space indent is a heading to CommonMark, and this line now
2120
+ // accepts one. The old `^#` refused it while `projectContextSectionBounds`
2121
+ // one function below already allowed it, so a guide indented by one space
2122
+ // had a locatable Project Context and an unrecognizable title \u2014 the two
2123
+ // halves of this very expression disagreeing.
2124
+ // - NARROWER: the old `\s*$` also tolerated a trailing U+00A0 / form feed /
2125
+ // vertical tab. CommonMark strips only spaces and tabs, so such a title is
2126
+ // genuinely a different heading, and it is now reported as unrecognizable
2127
+ // instead of being silently accepted. That direction is safe by
2128
+ // construction: the caller's response to "not recognizable" is a warning
2129
+ // plus leaving the user's file untouched, which is a loud, actionable
2130
+ // failure rather than a silent rewrite of a file we misread.
2131
+ function isRecognizableDflowGuide(lfContent) {
2132
+ const content = lfContent.replace(/^\uFEFF/, ''); // a BOM must not defeat the title line (gate G4)
2133
+ const scan = doctorChecks.blankFencedBlocks(content);
2134
+ const titled = doctorChecks.classifyLines(scan)
2135
+ .some((c) => c.heading && c.heading.level === 1 && c.heading.text === 'Dflow AI Agent Guide');
2136
+ return titled && projectContextSectionBounds(content) !== null;
2137
+ }
2138
+
2139
+ // Bounds of the "## Project Context" section in LF content: the heading line up
2140
+ // to (exclusive) the next "## " heading, the guide-canonical START marker, or EOF.
2141
+ // Fence-aware (PROPOSAL-076 gate G1): headings or markers inside ``` / ~~~
2142
+ // examples are content, not structure — the boundary scan runs on a
2143
+ // fence-blanked shadow while the returned lines stay the real content.
2144
+ // \u26A0 Heading detection here goes through `doctorChecks.classifyLines`, and that
2145
+ // is the point rather than a tidy-up. This function used to hand-roll its own
2146
+ // ATX rules, which made it a FOURTH place deciding what a heading is; `g5`
2147
+ // finding 2 caught it a patch behind its siblings \u2014 it had taken the 0-3 space
2148
+ // indent but not the "space or tab after the hashes" rule, so a bare `##` did
2149
+ // not end the section and a `| Tech stack |` row below it was read as though it
2150
+ // were still inside Project Context. Routing it through the classification is
2151
+ // what makes that class of lag impossible rather than merely fixed once.
2152
+ //
2153
+ // Two consequences of using the shared rules, both verified against the
2154
+ // packaged guides in `test/upgrade-drift.mjs`:
2155
+ // - the section now also ends at a LEVEL-1 heading, not only an H2. Both
2156
+ // packaged guides carry exactly one H1 and it is line 1, above Project
2157
+ // Context, so nothing packaged moves; an adopter's guide with an H1 further
2158
+ // down now ends the section there, which is what CommonMark says.
2159
+ // - `## Project Context ##` and a setext-underlined `Project Context` are now
2160
+ // found, where the old literal test saw neither.
2161
+ function projectContextSectionBounds(lfContent) {
2162
+ const stripped = lfContent.replace(/^\uFEFF/, ''); // keep BOM out of the line-0 heading match (gate G4)
2163
+ const lines = stripped.split('\n');
2164
+ const scan = doctorChecks.blankFencedBlocks(stripped);
2165
+ const info = doctorChecks.classifyLines(scan);
2166
+ // ⚠⚠ `heading.start`, NOT the array index. `classifyLines` reports a SETEXT
2167
+ // heading at its UNDERLINE line, and carries the real first line in
2168
+ // `heading.start`. Using the index here — while this function had just been
2169
+ // widened to recognize setext headings at all — meant the returned slice began
2170
+ // at the underline: `transplantProjectContext` then dropped the heading text
2171
+ // `Project Context`, swallowed the NEXT section's heading text into the kept
2172
+ // region, and wrote the result to the user's guide. Re-parsing that output
2173
+ // returns null, so the guide becomes permanently unrecognizable and
2174
+ // `configure-agents` refuses to touch it again — all behind an interactive
2175
+ // offer whose text promises "your Project Context section is kept".
2176
+ // CONTENT LOSS, on disk. `conventionsSectionBodies` had this right; this
2177
+ // function was widened without taking the same field with it.
2178
+ const at = info.findIndex((c) => c.heading && c.heading.level === 2 && c.heading.text === 'Project Context');
2179
+ if (at < 0) return null;
2180
+ const start = info[at].heading.start;
2181
+ let end = scan.length;
2182
+ for (let i = at + 1; i < scan.length; i += 1) {
2183
+ if (info[i].heading && info[i].heading.level <= 2) {
2184
+ end = info[i].heading.start;
2185
+ break;
2186
+ }
2187
+ if (scan[i].startsWith(GUIDE_CANONICAL_SECTION_START)) {
2188
+ end = i;
2189
+ break;
2190
+ }
2191
+ }
2192
+ return { lines, start, end };
2193
+ }
2194
+
2195
+ // PROPOSAL-058 bootstrap: rebuild the guide from the packaged template, carrying
2196
+ // over the project's own "## Project Context" section (trailing blank lines normalized; the only
2197
+ // user-specific region by contract). Everything else — including any prose the
2198
+ // user kept outside Project Context — is replaced; the interactive offer says so
2199
+ // and defaults to No.
2200
+ function transplantProjectContext(packagedGuide, existingLf) {
2201
+ const existing = projectContextSectionBounds(existingLf);
2202
+ const packaged = projectContextSectionBounds(packagedGuide);
2203
+ if (!existing || !packaged) {
2204
+ throw new InitError('Internal error: cannot locate "## Project Context" while adopting guide markers.');
2205
+ }
2206
+ const existingSection = existing.lines.slice(existing.start, existing.end);
2207
+ // ⚠ `BLANK_LINE_RE` and not `.trim()`: a line holding only a U+00A0 is NOT
2208
+ // blank to CommonMark, and `.trim()` would pop it off the end of a section
2209
+ // this function is about to write back to a user-owned file.
2210
+ // ⚠ Imported rather than written inline. The first fix here spelled
2211
+ // `/^[ \t]*$/` by hand, which made it a THIRD copy of the rule while the
2212
+ // commit message claimed there were only two — the same "two expressions, one
2213
+ // rule" shape the whole rewrite exists to remove.
2214
+ while (existingSection.length > 0
2215
+ && doctorChecks.BLANK_LINE_RE.test(existingSection[existingSection.length - 1])) {
2216
+ existingSection.pop();
2217
+ }
2218
+ return [
2219
+ ...packaged.lines.slice(0, packaged.start),
2220
+ ...existingSection,
2221
+ '',
2222
+ ...packaged.lines.slice(packaged.end)
2223
+ ].join('\n');
2224
+ }
2225
+
2226
+ // PROPOSAL-058: the interactive adoption questions. Only a TTY run ever asks
2227
+ // (the PROPOSAL-074 non-TTY contract: never consume a stdin slot); blank answers
2228
+ // mean No because both offers rewrite a user-owned file.
2229
+ async function askGuideMarkerAdoption(rl, stdout) {
2230
+ stdout.write(
2231
+ `\nYour ${AI_AGENT_GUIDE_DEST} predates Dflow's managed canonical markers, so\n` +
2232
+ 'upgrades cannot refresh its canonical sections in place. Adopting the markers\n' +
2233
+ 'replaces everything OUTSIDE "## Project Context" with this Dflow version\'s\n' +
2234
+ 'canonical guide content (your Project Context section is kept).\n' +
2235
+ 'Answer N if you customized any other guide section.\n'
2236
+ );
2237
+ return askConfirmation(rl, 'Adopt the managed guide markers now? (y/N) ');
2238
+ }
2239
+
2240
+ async function askGitPrinciplesMarkerAdoption(rl, stdout) {
2241
+ const nl = String.fromCharCode(10);
2242
+ stdout.write(
2243
+ nl +
2244
+ "Your Git-principles-*.md predates Dflow's managed canonical markers, so" + nl +
2245
+ 'upgrades cannot refresh its canonical sections in place. Adopting the markers' + nl +
2246
+ "replaces sections 1-5 with this Dflow version's content; your file header and" + nl +
2247
+ 'everything from "## 6. AI Collaboration Rules (Project Policy)" down —' + nl +
2248
+ 'including your CI / CD section — is kept as you wrote it. (Line endings are' + nl +
2249
+ 'unified to whichever this file already uses most, as on every write.)' + nl +
2250
+ 'Answer N if you customized any of sections 1-5.' + nl
2251
+ );
2252
+ return askConfirmation(rl, 'Adopt the managed Git principles markers now? (y/N) ');
2253
+ }
2254
+
2255
+ async function askShimBlockAdoption(rl, stdout, agent) {
2256
+ const relativePath = getAiAgentTarget(agent).relativePath;
2257
+ stdout.write(
2258
+ `\n${relativePath} references the Dflow guide but is not Dflow-managed (no markers),\n` +
2259
+ 'so upgrades cannot refresh any Dflow wording inside it. Dflow can append its\n' +
2260
+ 'managed, marker-delimited block at the end of the file; afterwards remove any\n' +
2261
+ 'older Dflow wording you keep above the block.\n'
2262
+ );
2263
+ return askConfirmation(rl, `Append the managed Dflow block to ${relativePath}? (y/N) `);
2264
+ }
2265
+
2266
+ // PROPOSAL-058 (user decision 2026-06-08, OQ2): `> Dflow Version:` in
2267
+ // _conventions.md means "the Dflow version this project last reconciled with",
2268
+ // so a successful configure-agents run must advance it — before this it froze at
2269
+ // the init version, which was a bug, not a design. Narrow single-line rewrite of
2270
+ // a user-owned file: previewed like every plan item, guarded by the rootInject
2271
+ // raw-equality check, and never *added* when the line is absent (doctor reports
2272
+ // that case instead).
2273
+ async function addConventionsVersionReconcileItem(cwd, items) {
2274
+ const relativePath = 'dflow/specs/shared/_conventions.md';
2275
+ const absolute = path.join(cwd, relativePath);
2276
+ if (!(await pathExists(absolute))) return;
2277
+ const existingContent = await fs.readFile(absolute, 'utf8');
2278
+ const lf = toLf(existingContent);
2279
+ const match = lf.match(/^> Dflow Version:[ \t]*(.*)$/m);
2280
+ if (!match || match[1].trim() === pkg.version) return;
2281
+ const eol = detectDominantEol(existingContent);
2282
+ const updated = lf.replace(/^> Dflow Version:[ \t]*.*$/m, `> Dflow Version: ${pkg.version}`);
2283
+ pushRootInjectItem(items, {
2284
+ relativePath,
2285
+ source: 'generated:dflow-version-reconcile',
2286
+ notes: `update Dflow Version line to ${pkg.version} (last reconciled)`,
2287
+ content: applyEol(updated, eol),
2288
+ expectedContent: existingContent
2289
+ });
2290
+ // See the call-site comment: never advance the line over a guarded skip.
2291
+ items[items.length - 1].requiresFullApply = true;
2292
+ }
2293
+
1626
2294
  // PROPOSAL-054: configure a tool's root agent file. A non-guide existing file used
1627
2295
  // to be parked as a side merge snippet ("hand-merge this yourself"); it is now an
1628
2296
  // auto-injected, marker-delimited Dflow block shown in the confirmation preview.
@@ -1673,7 +2341,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1673
2341
 
1674
2342
  const existingContent = await fs.readFile(targetPath, 'utf8');
1675
2343
  const eol = detectDominantEol(existingContent);
1676
- const lf = existingContent.replace(/\r\n/g, '\n');
2344
+ const lf = toLf(existingContent);
1677
2345
  const agentRegion = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
1678
2346
  // Classify the Codex trigger region on EVERY AGENTS.md run (not only when we are
1679
2347
  // about to manage the trigger): even a non---command-adapters run replaces the
@@ -1779,7 +2447,40 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1779
2447
  // marker-managed (a guide-configured file the user wrote / heavily edited). Keep
1780
2448
  // the base shim skipped so we never duplicate their guide pointer. Under Codex
1781
2449
  // --command-adapters still install / update the self-delimited trigger block (OQ#6c).
2450
+ // PROPOSAL-058: any Dflow wording inside such a file is frozen (old canonical
2451
+ // prose and user prose cannot be told apart mechanically), so an interactive run
2452
+ // offers to append the managed block instead — with consent the file becomes
2453
+ // marker-managed (future runs refresh it via case 2b) and the user removes their
2454
+ // older Dflow wording; without consent (or non-TTY) the 2d behavior is unchanged.
1782
2455
  if (contentReferencesAiAgentGuide(existingContent)) {
2456
+ const adoptShimAgents = options.adoptShimAgents || [];
2457
+ if (adoptShimAgents.includes(agent)) {
2458
+ let updated = appendBlockLf(lf, agentShimBlock);
2459
+ if (wantsTrigger) {
2460
+ updated = upsertCodexTriggerBlock(updated, triggerBlock);
2461
+ }
2462
+ if (warnings) {
2463
+ warnings.push(
2464
+ `Appended the managed Dflow block to ${target.relativePath}; future upgrades refresh it in place. Review the file and remove any older Dflow wording outside the marked block.`
2465
+ );
2466
+ }
2467
+ pushRootInjectItem(items, {
2468
+ relativePath: target.relativePath,
2469
+ source,
2470
+ notes: `selected, appended managed Dflow block to existing ${target.relativePath}`,
2471
+ content: applyEol(updated, eol),
2472
+ expectedContent: existingContent
2473
+ });
2474
+ return;
2475
+ }
2476
+ // No consent (declined, or a non-interactive run): keep the 2d skip, but say
2477
+ // so — the docs promise "skip and warn", and the frozen Dflow wording is
2478
+ // exactly the drift this proposal makes visible.
2479
+ if (warnings) {
2480
+ warnings.push(
2481
+ `${target.relativePath} references the Dflow guide but is not marker-managed; Dflow wording inside it stays frozen on upgrade. Re-run \`dflow configure-agents\` on an interactive terminal to accept the managed-block offer, or keep maintaining the file yourself (\`dflow doctor\` reports this state).`
2482
+ );
2483
+ }
1783
2484
  if (wantsTrigger) {
1784
2485
  pushRootInjectItem(items, {
1785
2486
  relativePath: target.relativePath,
@@ -1788,6 +2489,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1788
2489
  content: applyEol(upsertCodexTriggerBlock(lf, triggerBlock), eol),
1789
2490
  expectedContent: existingContent
1790
2491
  });
2492
+ items[items.length - 1].offerShimAdoption = agent;
1791
2493
  } else {
1792
2494
  items.push({
1793
2495
  relativePath: target.relativePath,
@@ -1796,6 +2498,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1796
2498
  content: fullShim,
1797
2499
  action: 'skip',
1798
2500
  intentionalSkip: true,
2501
+ offerShimAdoption: agent,
1799
2502
  size: Buffer.byteLength(fullShim, 'utf8')
1800
2503
  });
1801
2504
  }
@@ -1879,7 +2582,7 @@ function appendBlockLf(lfContent, block) {
1879
2582
  // dominant EOL (the approved EOL policy), so a pure LF / pure CRLF user prefix
1880
2583
  // round-trips byte-for-byte. `blocks` are LF strings.
1881
2584
  function appendDflowBlocks(existingContent, blocks, eol) {
1882
- const lf = existingContent.replace(/\r\n/g, '\n');
2585
+ const lf = toLf(existingContent);
1883
2586
  return applyEol(appendBlockLf(lf, blocks.join('\n\n')), eol);
1884
2587
  }
1885
2588
 
@@ -1907,14 +2610,23 @@ function extractCodexTriggerBlock(content) {
1907
2610
  // The markers are single-line HTML comments, so classification is identical on raw or
1908
2611
  // LF-normalized content; callers slice on whichever string they passed in.
1909
2612
  function classifyMarkedRegion(content, startMarker, endMarker) {
1910
- const startCount = countOccurrences(content, startMarker);
1911
- const endCount = countOccurrences(content, endMarker);
2613
+ // ⚠ SEARCH THE MASK, SLICE THE ORIGINAL. A marker shown inside a fenced or
2614
+ // indented code example is documentation, not a region boundary — treating it
2615
+ // as one overwrote a user's own example with generated content, silently
2616
+ // (`p082-b3-k3` finding 1). `maskCodeBlocks` preserves every offset, so the
2617
+ // indices returned here still address `content`.
2618
+ // ⚠ This is the same defect class as the one the conventions checks had: a
2619
+ // consumer deciding a structural question by not asking the classifier. It is
2620
+ // fixed here by asking.
2621
+ const searchable = doctorChecks.maskCodeBlocks(content);
2622
+ const startCount = countOccurrences(searchable, startMarker);
2623
+ const endCount = countOccurrences(searchable, endMarker);
1912
2624
  if (startCount === 0 && endCount === 0) {
1913
2625
  return { state: 'absent' };
1914
2626
  }
1915
2627
  if (startCount === 1 && endCount === 1) {
1916
- const startIdx = content.indexOf(startMarker);
1917
- const endInner = content.indexOf(endMarker);
2628
+ const startIdx = searchable.indexOf(startMarker);
2629
+ const endInner = searchable.indexOf(endMarker);
1918
2630
  if (startIdx < endInner) {
1919
2631
  return { state: 'present', startIdx, endIdx: endInner + endMarker.length };
1920
2632
  }
@@ -1963,6 +2675,30 @@ function codexTriggerMarkersStraddle(content, start, end) {
1963
2675
  // Dominant line ending of a user file, so injected blocks match it (Windows projects
1964
2676
  // may be CRLF). The repo's own LF policy (.gitattributes) governs repo files only, not
1965
2677
  // an adopter's project files.
2678
+ // Normalize any line ending to LF — CRLF **and a lone CR**, CommonMark's third
2679
+ // form. The lone-CR half is not pedantry. `lfContent` / `existingLf` are threaded
2680
+ // through the guide pipeline as a PROMISE that the content is LF, and
2681
+ // `projectContextSectionBounds` splits it on newline while its fence-aware scan
2682
+ // goes through `blankFencedBlocks`. While both sides ignored a lone CR they were
2683
+ // wrong together and therefore harmless — such a guide simply was not
2684
+ // recognized. Fixing only the scan side (`p082-b3-g1` finding 3) made them
2685
+ // DISAGREE: a CR-only guide became "recognizable", its Project Context sliced to
2686
+ // nothing, and `configure-agents` exited 0 having transplanted an EMPTY section
2687
+ // — silently discarding the one region that whole path exists to preserve.
2688
+ // Caught by `p082-b3-g2` finding 1, a regression this batch introduced.
2689
+ //
2690
+ // The lesson is the one this batch keeps paying for: a normalization is a
2691
+ // contract between everyone who reads the value, so it needs ONE definition.
2692
+ // Six call sites had each spelled the CRLF replacement out by hand, so "is this
2693
+ // LF now?" had six answers that agreed only by luck.
2694
+ //
2695
+ // ⚠ Consequence worth knowing: a CR-only file is rewritten with LF endings.
2696
+ // `detectDominantEol` only distinguishes CRLF from LF and already returned LF
2697
+ // for such a file, so this makes existing behaviour honest rather than new.
2698
+ function toLf(content) {
2699
+ return String(content).replace(/\r\n|\r/g, '\n');
2700
+ }
2701
+
1966
2702
  function detectDominantEol(content) {
1967
2703
  const crlf = (content.match(/\r\n/g) || []).length;
1968
2704
  const lfOnly = (content.match(/\n/g) || []).length - crlf;
@@ -1970,7 +2706,7 @@ function detectDominantEol(content) {
1970
2706
  }
1971
2707
 
1972
2708
  function applyEol(content, eol) {
1973
- const normalized = content.replace(/\r\n/g, '\n');
2709
+ const normalized = toLf(content);
1974
2710
  return eol === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
1975
2711
  }
1976
2712
 
@@ -1993,10 +2729,14 @@ function getAiAgentTarget(agent) {
1993
2729
  return targets[agent];
1994
2730
  }
1995
2731
 
1996
- function buildAiAgentShim(targetPath, commandRegistry = []) {
1997
- const title = targetPath === '.github/copilot-instructions.md'
2732
+ function shimTitle(targetPath) {
2733
+ return targetPath === '.github/copilot-instructions.md'
1998
2734
  ? 'GitHub Copilot Repository Instructions'
1999
2735
  : `${targetPath} - Dflow Project Instructions`;
2736
+ }
2737
+
2738
+ function buildAiAgentShim(targetPath, commandRegistry = []) {
2739
+ const title = shimTitle(targetPath);
2000
2740
 
2001
2741
  const commandTriggerHint = targetPath === 'AGENTS.md' && commandRegistry.length > 0
2002
2742
  ? buildCodexCommandTriggerSection(commandRegistry)
@@ -2012,8 +2752,17 @@ domain behavior, a new requirement, or a bug-fix workflow — read and follow:
2012
2752
  - \`dflow/specs/shared/AI-AGENT-GUIDE.md\` — command registry, routing rules, and project context.
2013
2753
  - \`dflow/specs/shared/dflow-workflows/\` — vendored workflow bundle with executable step definitions.
2014
2754
 
2015
- For routine work (refactors, renames, chores, formatting, dependency bumps, or
2016
- general code questions), proceed normally; you need not read the guide first.
2755
+ For routine work (refactors, renames, chores, formatting, routine dependency
2756
+ bumps, or general code questions), proceed normally; you need not read the guide
2757
+ first. **Routine is narrower than it sounds** — it excludes anything a product
2758
+ audience perceives (UI, email, exports, public docs such as a product README or
2759
+ API reference, and operator surfaces like dashboard labels and alerts), where
2760
+ **size is not the test**: a single-element wording or appearance change still
2761
+ counts. It also excludes anything touching architecture, data structure, a
2762
+ machine-consumed contract, a BR-ID, operational semantics (security / CVE,
2763
+ safety, resilience, compliance, payment), or deliberate performance / resource /
2764
+ SLA work. When unsure, read the guide's § Ceremony Scaling —
2765
+ it decides, not this page.
2017
2766
 
2018
2767
  Keep tool-specific instruction files small. The guide and workflow bundle are
2019
2768
  the authoritative sources for Dflow workflow rules, slash-command behavior,
@@ -2021,17 +2770,82 @@ spec locations, and SDD/DDD constraints.${commandTriggerHint}
2021
2770
  `;
2022
2771
  }
2023
2772
 
2024
- // Frozen pre-scoping shim body (the wording shipped through v0.9.0 and the
2025
- // Phase-2 @import-removal interim: "Before planning or editing code ..."). Used
2026
- // ONLY by isPristineDflowAgentsShim so an adopter's older whole-file shim is
2027
- // still recognized as Dflow-generated and regenerated to the current scoped
2028
- // wording. Changing buildAiAgentShim's body without updating this matcher would
2029
- // strand old shims on the guide-reference skip path — they would keep the old
2030
- // body, and for CLAUDE.md the legacy @import.
2773
+ // ⚠⚠ READ BEFORE EDITING ANY VERSION NUMBER BELOW. The version→body map has now
2774
+ // been corrected in three consecutive review rounds, each time in a new way:
2775
+ // first the whole oldest body was missing; then the range was written as
2776
+ // starting at v0.1.0 (it starts at v0.1.1); then the handover to the next body
2777
+ // was pinned to v0.9.0 (it is 0.8.0). Per this repo's rule that a thing rewritten
2778
+ // three times is a design question rather than a fourth patch, the cause is
2779
+ // named here instead of the numbers being nudged again:
2780
+ //
2781
+ // **`git tag` is NOT the list of releases.** 0.8.0 shipped to npm and has no
2782
+ // tag — `git tag` jumps v0.7.0 → v0.9.0 — so any range re-derived from tags
2783
+ // alone will silently skip it.
2784
+ //
2785
+ // ⚠ `CHANGELOG.md` is not the list either, and an earlier version of this note
2786
+ // sent people there: it has no `## 0.13.0` heading — that entry was folded
2787
+ // away by the 0.14.0 release prep — so the same kind of hole reappears one
2788
+ // version along. Nor does `-S'"version": "0.X.0"'` enumerate: it can only
2789
+ // confirm a version you already suspect, and `X` is the unknown.
2790
+ //
2791
+ // **The one command that actually enumerates** (15 bumps, including untagged
2792
+ // 0.8.0 and CHANGELOG-less 0.13.0):
2793
+ //
2794
+ // git log -G'"version": "0\.' --oneline -- package.json
2795
+ //
2796
+ // Then read `buildAiAgentShim` at that release commit — not at the nearest tag.
2797
+ //
2798
+ // Nothing in the runtime depends on these numbers: `.some()` short-circuits over
2799
+ // the builders and no two shipped bodies normalize equal. They exist so a
2800
+ // maintainer can tell whether the list reaches all the way back — which is
2801
+ // exactly why being wrong about them has cost three rounds.
2802
+ //
2803
+ // Frozen pre-bundle shim body: the oldest body `buildAiAgentShim` ever
2804
+ // generated, carried by v0.1.1 through v0.7.0. One guide bullet, no
2805
+ // workflow-bundle bullet, and a "The Dflow guide above is the single source of
2806
+ // truth" tail. Introduced by `eabee5c` (earliest release tag v0.1.1) and
2807
+ // replaced by `5498120` (PROPOSAL-039, vendored workflow bundle), which first
2808
+ // shipped in **0.8.0** (release commit `66681d6`, untagged).
2809
+ //
2810
+ // ⚠ NOT "the first body Dflow ever shipped", and the range does not reach
2811
+ // v0.1.0. At v0.1.0 `lib/init.js` did not reference the guide at all — it wrote
2812
+ // CLAUDE.md from the packaged `CLAUDE-md-snippet.md` via a different code path,
2813
+ // producing a two-H2 `System Context` / `Development Workflow` document with
2814
+ // per-project values substituted in. That body is deliberately NOT in this list
2815
+ // and cannot be: a frozen builder matches a fixed string, and a
2816
+ // placeholder-substituted document has no fixed form to match. A v0.1.0 project
2817
+ // therefore degrades to the not-marker-managed branch, which is correct
2818
+ // behaviour for a file whose content is partly the developer's.
2819
+ //
2820
+ // It was missing from the frozen set until 2026-08-03, and the omission was
2821
+ // live: a project still carrying this body failed the match, fell to the
2822
+ // "not marker-managed" branch and kept the v0.1-v0.7 wording permanently.
2823
+ // Confirmed by planting it in a real project and running `configure-agents`.
2824
+ // The list comment said "oldest first" while starting at the second-oldest.
2825
+ function buildPreBundleAgentShimBody(targetPath) {
2826
+ const title = shimTitle(targetPath);
2827
+
2828
+ return `# ${title}
2829
+
2830
+ This project uses Dflow for spec-first AI-assisted development.
2831
+
2832
+ Before planning or editing code, read and follow:
2833
+
2834
+ - \`dflow/specs/shared/AI-AGENT-GUIDE.md\`
2835
+
2836
+ Keep tool-specific instruction files small. The Dflow guide above is the
2837
+ single source of truth for project workflow rules, slash-command behavior,
2838
+ spec locations, and SDD/DDD constraints.
2839
+ `;
2840
+ }
2841
+
2842
+ // Frozen pre-scoping shim body: shipped in **0.8.0 and v0.9.0** (and the
2843
+ // Phase-2 @import-removal interim) — "Before planning or editing code ...".
2844
+ // Its old wording is INTENTIONAL — this function exists to recognize it. See
2845
+ // FROZEN_SHIM_BODY_BUILDERS below for why, and the ⚠⚠ note above
2846
+ // buildPreBundleAgentShimBody before touching the version numbers.
2031
2847
  function buildLegacyAgentShimBody(targetPath) {
2032
- const title = targetPath === '.github/copilot-instructions.md'
2033
- ? 'GitHub Copilot Repository Instructions'
2034
- : `${targetPath} - Dflow Project Instructions`;
2848
+ const title = shimTitle(targetPath);
2035
2849
 
2036
2850
  return `# ${title}
2037
2851
 
@@ -2048,6 +2862,79 @@ spec locations, and SDD/DDD constraints.
2048
2862
  `;
2049
2863
  }
2050
2864
 
2865
+ // Frozen unqualified-routine shim body (shipped 0.10.0 through 0.14.0): the
2866
+ // scoped wording before PROPOSAL-082 narrowed "routine". Its routine paragraph
2867
+ // waved through the classes the cascade calls tracked — a security dep bump, an
2868
+ // operational-axis refactor, a Domain rename — and told the agent not to read
2869
+ // the guide, so the shim contradicted the guide it points at.
2870
+ function buildUnqualifiedRoutineShimBody(targetPath) {
2871
+ const title = shimTitle(targetPath);
2872
+
2873
+ return `# ${title}
2874
+
2875
+ This project uses Dflow for spec-first AI-assisted development.
2876
+
2877
+ For spec-impacting work — a new feature, a change to product, user-facing, or
2878
+ domain behavior, a new requirement, or a bug-fix workflow — read and follow:
2879
+
2880
+ - \`dflow/specs/shared/AI-AGENT-GUIDE.md\` — command registry, routing rules, and project context.
2881
+ - \`dflow/specs/shared/dflow-workflows/\` — vendored workflow bundle with executable step definitions.
2882
+
2883
+ For routine work (refactors, renames, chores, formatting, dependency bumps, or
2884
+ general code questions), proceed normally; you need not read the guide first.
2885
+
2886
+ Keep tool-specific instruction files small. The guide and workflow bundle are
2887
+ the authoritative sources for Dflow workflow rules, slash-command behavior,
2888
+ spec locations, and SDD/DDD constraints.
2889
+ `;
2890
+ }
2891
+
2892
+ // Every shim body Dflow has shipped, oldest first. Used ONLY by
2893
+ // isPristineDflowAgentsShim, so an adopter carrying any previously-generated
2894
+ // whole-file shim is still recognized as Dflow-generated and regenerated to the
2895
+ // current wording.
2896
+ //
2897
+ // ⚠ CHANGING buildAiAgentShim's BODY MEANS APPENDING THE OUTGOING BODY HERE, in
2898
+ // the same commit. Miss it and every adopter on the previous release is stranded
2899
+ // on the guide-reference skip path: their shim stops matching, degrades to the
2900
+ // "not marker-managed" branch, and keeps the superseded body forever — plus, for
2901
+ // CLAUDE.md, the legacy @import. The failure is silent on the maintainer's box,
2902
+ // because a freshly-generated shim always matches the body it was just built
2903
+ // from; only an upgrade from a real older project shows it.
2904
+ //
2905
+ // The rule has exactly one exception, and it follows from that failure mode: an
2906
+ // outgoing body that **never appeared in a release** has no adopters to strand,
2907
+ // so it does not go in the list. ⚠ Establish that from the version-bump history
2908
+ // (the enumerating command in the ⚠⚠ note above), NOT from `git tag --contains`
2909
+ // — that note says why `git tag` is not the list of releases, and a body
2910
+ // introduced and replaced *between* two releases is exactly where the tag test
2911
+ // and the truth diverge. Taking the exception is not free: a project scaffolded
2912
+ // from this repo while the orphan body was current carries it, and
2913
+ // `configure-agents` then refuses to regenerate that file in place — loudly,
2914
+ // via the "not marker-managed" warning, leaving the file untouched.
2915
+ //
2916
+ // This is a list rather than a single fallback because the one-body form had to
2917
+ // be edited correctly at exactly the moment a body changed, which is the moment
2918
+ // attention is on the new wording. `test/agent-inject.mjs` carries one upgrade
2919
+ // case per entry (§3b / §3e / §3f, plus §3g for the AGENTS.md+trigger
2920
+ // composition) — NOT upgrade-drift.mjs, which has no
2921
+ // shim-body case at all.
2922
+ //
2923
+ // ⚠ Adding a builder here means adding its case there in the same commit. That
2924
+ // pairing is not machine-enforced; the list can grow without a test.
2925
+ //
2926
+ // The v0.1-v0.7 entry was missing from the first version of this list, so the
2927
+ // list shipped one release while contradicting its own "oldest first" label and
2928
+ // stranding the very adopters it names. Order is documentation only —
2929
+ // `.some()` short-circuits and no two shipped bodies normalize equal — but the
2930
+ // label has to be true, because it is what tells the next maintainer whether
2931
+ // the list reaches all the way back.
2932
+ const FROZEN_SHIM_BODY_BUILDERS = [
2933
+ buildPreBundleAgentShimBody,
2934
+ buildLegacyAgentShimBody,
2935
+ buildUnqualifiedRoutineShimBody
2936
+ ];
2937
+
2051
2938
  function addCommandAdapterItems(items, aiAgents, commandRegistry) {
2052
2939
  if (aiAgents.includes('claude')) {
2053
2940
  for (const command of commandRegistry) {
@@ -2231,12 +3118,11 @@ ${argHint}
2231
3118
  }
2232
3119
 
2233
3120
  function normalizeCommandAdapterFingerprint(content) {
2234
- return String(content).replace(/\r\n/g, '\n');
3121
+ return toLf(content);
2235
3122
  }
2236
3123
 
2237
3124
  function normalizeShimForMatch(content) {
2238
- return String(content)
2239
- .replace(/\r\n/g, '\n')
3125
+ return toLf(content)
2240
3126
  .split('\n')
2241
3127
  .map((line) => line.replace(/[ \t]+$/, ''))
2242
3128
  .join('\n')
@@ -2283,12 +3169,13 @@ function isPristineDflowAgentsShim(existingContent, baseShim, relativePath) {
2283
3169
  if (existing === target) {
2284
3170
  return true;
2285
3171
  }
2286
- // Back-compat: older Dflow shims (v0.9.0 / Phase-2 interim) used different body
2287
- // wording ("Before planning or editing code ..."). Recognize that frozen
2288
- // wording as pristine so configure-agents regenerates it to the current scoped
2289
- // wording (and, for CLAUDE.md, drops the legacy @import already stripped above).
2290
- // Without this, the body reword would strand old shims on the skip path.
2291
- return existing === normalizeShimForMatch(buildLegacyAgentShimBody(relativePath));
3172
+ // Back-compat: every shim body Dflow has previously shipped counts as pristine,
3173
+ // so configure-agents regenerates it to the current wording (and, for CLAUDE.md,
3174
+ // drops the legacy @import already stripped above). Without this, each body
3175
+ // reword would strand the previous release's shims on the skip path.
3176
+ return FROZEN_SHIM_BODY_BUILDERS.some(
3177
+ (buildFrozenBody) => existing === normalizeShimForMatch(buildFrozenBody(relativePath))
3178
+ );
2292
3179
  }
2293
3180
 
2294
3181
  function buildCodexCommandTriggerSection(commandRegistry) {
@@ -2462,13 +3349,13 @@ function validateDflowCommandRegistryRow(command, seen) {
2462
3349
  }
2463
3350
  }
2464
3351
 
3352
+ // Greenfield: the five common rows then `events.md` and the ADR row — the same
3353
+ // seven paths, in the same order, that `test/upgrade-drift.mjs` pins.
3354
+ // Brownfield: the five common rows only.
2465
3355
  function buildDeferredItems(edition) {
2466
3356
  const deferred = [...DEFERRED_COMMON];
2467
3357
  if (edition === 'greenfield') {
2468
- deferred.splice(3, 0, {
2469
- relativePath: 'dflow/specs/domain/{context}/events.md',
2470
- reason: 'Greenfield only, but still needs a real bounded context.'
2471
- });
3358
+ deferred.push(...DEFERRED_GREENFIELD_ONLY);
2472
3359
  }
2473
3360
  return deferred;
2474
3361
  }
@@ -2517,8 +3404,13 @@ function buildSubstitutionMap(cwd, answers) {
2517
3404
  ['{系統名稱}', systemName],
2518
3405
  ['{project-type}', answers.projectType],
2519
3406
  ['{edition}', answers.edition],
2520
- ['{tech-stack-summary}', answers.techStackSummary],
2521
- ['{migration-context}', answers.migrationContext],
3407
+ // These two land in the guide's "## Project Context" table cells (their
3408
+ // only template use), so bare `|` in the free-text answers must be escaped
3409
+ // or the row gains phantom cells and inference later truncates the value —
3410
+ // escapeTableCell is the exact inverse of parseContextLine's unescape
3411
+ // (PROPOSAL-076 gate G2 round-trip fix).
3412
+ ['{tech-stack-summary}', escapeTableCell(answers.techStackSummary)],
3413
+ ['{migration-context}', escapeTableCell(answers.migrationContext)],
2522
3414
  ['{prose-language}', answers.proseLanguage],
2523
3415
  ['{dflow-version}', pkg.version],
2524
3416
  ['{Language}', extracted.language || '{Language}'],
@@ -2543,6 +3435,28 @@ function buildSubstitutionMap(cwd, answers) {
2543
3435
  return map;
2544
3436
  }
2545
3437
 
3438
+ // Every placeholder token `substitutePlaceholders` can replace, derived from the
3439
+ // map itself so it cannot go stale. Exported for the guard that keeps
3440
+ // marker-delimited canonical regions substitution-free: those regions are
3441
+ // compared and rewritten as RAW PACKAGED BYTES, so a substituted token inside
3442
+ // one makes a freshly-projected file differ from its own packaged source.
3443
+ // ⚠ The answers are deliberately irrelevant here. `p090-b3-y1` caught the first
3444
+ // version of that guard proving only what its fixed init answers happened to
3445
+ // substitute: `{ORM / persistence}` maps to ITSELF when no ORM is named, so it
3446
+ // survived projection unchanged and the byte-comparison passed over it — while
3447
+ // an adopter who does name an ORM would get exactly the reported defect.
3448
+ function placeholderTokens() {
3449
+ const probe = buildSubstitutionMap(process.cwd(), {
3450
+ techStackSummary: '',
3451
+ migrationContext: '',
3452
+ proseLanguage: '',
3453
+ projectType: '',
3454
+ edition: '',
3455
+ gitPolicy: null
3456
+ });
3457
+ return [...probe.keys()];
3458
+ }
3459
+
2546
3460
  function substitutePlaceholders(content, substitution) {
2547
3461
  let result = content;
2548
3462
  for (const [placeholder, value] of substitution.entries()) {
@@ -2693,6 +3607,20 @@ function extractTestFramework(text) {
2693
3607
  return null;
2694
3608
  }
2695
3609
 
3610
+ // ⚠ THIS FAMILY DELIBERATELY DOES NOT GO THROUGH `doctorChecks.classifyLines`,
3611
+ // and the boundary is worth stating because the rest of this file just moved the
3612
+ // other way (see `projectContextSectionBounds`). `ensureProseLanguageSection`,
3613
+ // `stripProseLanguageSections` and `stripNamedSections` only ever see content
3614
+ // this process generated moments earlier — `readPackagedTemplate` followed by
3615
+ // `substitutePlaceholders`, at the single call site in `addTemplate` — so the
3616
+ // heading shapes they must handle are the ones the packaged templates contain,
3617
+ // not the ones an adopter might type. They also fail LOUDLY on a mis-parse: the
3618
+ // `count !== 1` assertion below throws rather than writing a wrong file.
3619
+ //
3620
+ // The functions that DID move are the ones that read USER-OWNED files and ask
3621
+ // the same questions doctor asks. That is the line: parse a file someone else
3622
+ // wrote, use the shared classification; reshape a string you just rendered
3623
+ // yourself, a literal match is honest and the assertion covers it.
2696
3624
  function ensureProseLanguageSection(content, proseLanguage) {
2697
3625
  const section = buildProseLanguageSection(proseLanguage);
2698
3626
  let stripped = stripProseLanguageSections(content);
@@ -2861,10 +3789,28 @@ async function writeFilePlan(cwd, plan) {
2861
3789
  warnings: []
2862
3790
  };
2863
3791
 
3792
+ // PROPOSAL-058: an item flagged requiresFullApply (the `> Dflow Version:`
3793
+ // last-reconciled advance) may only run when every previewed change actually
3794
+ // applied. Guarded skips — changed-after-preview, vanished / non-file
3795
+ // targets, unexpected existing targets — mean the previewed reconciliation
3796
+ // is incomplete, so the flagged item is skipped with a warning instead of
3797
+ // overstating the reconciled version. Intentional skips (already current /
3798
+ // already configured) do not block it, and neither does removing a stale
3799
+ // file that is already gone (the desired end state holds).
3800
+ let unexpectedSkip = false;
3801
+
2864
3802
  for (const item of plan.items) {
2865
3803
  const targetPath = path.join(cwd, item.relativePath);
2866
3804
 
2867
3805
  try {
3806
+ if (item.requiresFullApply && unexpectedSkip) {
3807
+ result.skipped.push(item.relativePath);
3808
+ result.warnings.push(
3809
+ `Skipped the Dflow Version update in ${item.relativePath} because earlier planned changes were skipped after the preview; re-run \`dflow configure-agents\`.`
3810
+ );
3811
+ continue;
3812
+ }
3813
+
2868
3814
  if (item.action === 'remove') {
2869
3815
  let stats;
2870
3816
  try {
@@ -2879,6 +3825,7 @@ async function writeFilePlan(cwd, plan) {
2879
3825
  }
2880
3826
 
2881
3827
  if (!stats.isFile()) {
3828
+ unexpectedSkip = true;
2882
3829
  result.skipped.push(item.relativePath);
2883
3830
  result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
2884
3831
  continue;
@@ -2886,6 +3833,7 @@ async function writeFilePlan(cwd, plan) {
2886
3833
 
2887
3834
  const currentContent = await fs.readFile(targetPath, 'utf8');
2888
3835
  if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
3836
+ unexpectedSkip = true;
2889
3837
  result.skipped.push(item.relativePath);
2890
3838
  result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
2891
3839
  continue;
@@ -2908,6 +3856,7 @@ async function writeFilePlan(cwd, plan) {
2908
3856
  stats = await fs.stat(targetPath);
2909
3857
  } catch (error) {
2910
3858
  if (error.code === 'ENOENT') {
3859
+ unexpectedSkip = true;
2911
3860
  result.skipped.push(item.relativePath);
2912
3861
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} no longer exists; re-run to inject the Dflow block.`);
2913
3862
  continue;
@@ -2915,12 +3864,14 @@ async function writeFilePlan(cwd, plan) {
2915
3864
  throw error;
2916
3865
  }
2917
3866
  if (!stats.isFile()) {
3867
+ unexpectedSkip = true;
2918
3868
  result.skipped.push(item.relativePath);
2919
3869
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} is no longer a regular file; re-run to inject the Dflow block.`);
2920
3870
  continue;
2921
3871
  }
2922
3872
  const currentRaw = await fs.readFile(targetPath, 'utf8');
2923
3873
  if (currentRaw !== item.expectedContent) {
3874
+ unexpectedSkip = true;
2924
3875
  result.skipped.push(item.relativePath);
2925
3876
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} changed after the preview; re-run to inject the Dflow block.`);
2926
3877
  continue;
@@ -2948,6 +3899,7 @@ async function writeFilePlan(cwd, plan) {
2948
3899
  // already-current Dflow block) is expected, not a problem — don't emit the
2949
3900
  // generic "skipped existing target" warning for it.
2950
3901
  if (!item.intentionalSkip) {
3902
+ unexpectedSkip = true;
2951
3903
  result.warnings.push(`Skipped existing target: ${item.relativePath}`);
2952
3904
  }
2953
3905
  if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
@@ -2989,6 +3941,10 @@ async function writeFilePlan(cwd, plan) {
2989
3941
  }
2990
3942
 
2991
3943
  if (targetExists) {
3944
+ // A previewed create raced against a concurrent creation — the
3945
+ // previewed change did not apply, so it blocks requiresFullApply
3946
+ // items exactly like the pre-write guard skips above.
3947
+ unexpectedSkip = true;
2992
3948
  result.skipped.push(item.relativePath);
2993
3949
  result.warnings.push(`Skipped existing target: ${item.relativePath}`);
2994
3950
  continue;
@@ -3205,8 +4161,21 @@ async function runDoctor(options = {}) {
3205
4161
  }
3206
4162
 
3207
4163
  const findings = [];
4164
+ await checkConventionsParserUncertainty(cwd, findings);
3208
4165
  await checkConventionsDflowVersion(cwd, findings);
3209
- await checkOrphanedWorkflowBundleFiles(cwd, findings);
4166
+ await checkConventionsVersionReconciled(cwd, findings);
4167
+ await checkConventionsPolicyFormat(cwd, findings);
4168
+ await checkGuideCanonicalState(cwd, findings);
4169
+ await checkGuideProjectContextFormat(cwd, findings);
4170
+ await checkGuideSectionRefs(cwd, findings);
4171
+ await checkInitOnlyStarters(cwd, findings);
4172
+ await checkFeatureIndexShape(cwd, findings);
4173
+ await checkDocumentShapes(cwd, findings);
4174
+ await checkSpecTableConventionComment(cwd, findings);
4175
+ await checkRootAgentShims(cwd, findings);
4176
+ await checkAdapterAndSkillState(cwd, findings);
4177
+ await checkWorkflowBundleSourceAndOrphans(cwd, findings);
4178
+ await checkBundleManifestVersion(cwd, findings);
3210
4179
 
3211
4180
  printDoctorReport(stdout, cwd, findings);
3212
4181
  return 0;
@@ -3220,11 +4189,300 @@ async function runDoctor(options = {}) {
3220
4189
  }
3221
4190
  }
3222
4191
 
3223
- async function checkConventionsDflowVersion(cwd, findings) {
4192
+ // FOUR doctor checks read `dflow/specs/shared/_conventions.md`, and each used to
4193
+ // decide separately what an unusable file was. They agreed about ABSENT (all
4194
+ // early-returned) and disagreed about BLANK: the drift check in
4195
+ // `checkInitOnlyStarters` deliberately collapses a blank file to one finding —
4196
+ // its comment says three fingerprint findings about a file with no content is
4197
+ // noise — while these three carried on and emitted four more. So the
4198
+ // suppression was defeated by its own siblings and a blank file produced five
4199
+ // findings, which `p082-b3-g1` finding 1 caught.
4200
+ //
4201
+ // ⚠ THE COUPLING THIS CREATES IS THE THING TO WATCH. Returning null here means
4202
+ // these three checks say NOTHING about an absent or blank file, and that is only
4203
+ // safe because `checkInitOnlyStarters` always runs and always reports it. Both
4204
+ // facts are load-bearing: `runDoctor` calls it unconditionally, and its
4205
+ // `conventionsAbsent || !conventions.trim()` branch has no early return in front
4206
+ // of it. Break either and the worst state of the file becomes silent everywhere
4207
+ // — the exact failure this subsystem exists to prevent. `test/upgrade-drift.mjs`
4208
+ // pins the count for absent and blank so the coupling cannot rot quietly.
4209
+ async function readConventionsForCheck(cwd) {
3224
4210
  const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
3225
- if (!(await pathExists(conventionsPath))) return;
4211
+ if (!(await pathExists(conventionsPath))) return null;
3226
4212
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
3227
- if (!/^> Dflow Version:/m.test(content)) {
4213
+ return content.trim() ? content : null;
4214
+ }
4215
+
4216
+ // PROPOSAL-084. Two `affects` sets, split because the gaps fail through two
4217
+ // different mechanisms and merging them would overstate one of them.
4218
+ //
4219
+ // VISIBILITY gaps change which TEXT counts as live, so everything that reads the
4220
+ // file through `visibleTextLines` is affected.
4221
+ const CONVENTIONS_VISIBILITY_AFFECTS = [
4222
+ 'the `_conventions.md` convention-drift fingerprints',
4223
+ 'whether the `## Git Policy` / `## AI Commit Policy` / `## Prose Language` sections are present, and the policy values read from them',
4224
+ // ⚠ ADDED after `p084-y1` finding 4 measured it: the starter checks are DOWNSTREAM
4225
+ // of the policy value, not a sibling of it. `We use trunk here <!-- Selected Git
4226
+ // policy: `gitflow` -->` makes `parseContextLine` return `gitflow`, which passes
4227
+ // the value check, and `checkInitOnlyStarters` then reports on
4228
+ // `Git-principles-gitflow.md` while saying nothing about the file the project
4229
+ // actually uses. Naming the value but not what the value drives understates the
4230
+ // blast radius, and this list is printed to users as measured fact.
4231
+ 'the `Git-principles-*.md` starter checks, which are driven off the Git policy value',
4232
+ 'dangling `§` references from `_conventions.md` into `AI-AGENT-GUIDE.md`'
4233
+ ];
4234
+ // BOUNDARY gaps move where a SECTION ends. The policy VALUES are read line-wise
4235
+ // by `parseContextLine` and do not depend on section extent, so they are named as
4236
+ // unaffected rather than quietly folded in — an `affects` list is read as
4237
+ // measured, and padding it is the same false-precision this state exists to stop.
4238
+ const CONVENTIONS_BOUNDARY_AFFECTS = [
4239
+ 'the `_conventions.md` convention-drift fingerprints',
4240
+ 'whether the `## Git Policy` / `## AI Commit Policy` / `## Prose Language` sections are present (the policy VALUES are read line-wise and this shape does not affect them)'
4241
+ ];
4242
+
4243
+ // ⚠ SCOPE: the two files whose CONTENT doctor makes claims about —
4244
+ // `_conventions.md` and `AI-AGENT-GUIDE.md` — and the boundary is measured rather
4245
+ // than cautious. Running these over the packaged tree finds the inline-comment
4246
+ // shape in both tracks' `phase-spec.md` templates, where `## Problem Description
4247
+ // <!-- Fill timing: ... -->` is a deliberate authoring convention, so widening the
4248
+ // scan to every spec file would make every project report uncertain forever —
4249
+ // which is how a disclosure becomes noise the user learns to skip. The two scanned
4250
+ // files are clean in every PACKAGED copy, so a fresh init is silent
4251
+ // (invariant 4).
4252
+ //
4253
+ // ⚠ The guide was EXCLUDED in the first draft, on the stated ground that its
4254
+ // false-positive cost "has not been measured". `p084-y1` finding 8 measured it —
4255
+ // 0 hits across all seven guides in this tree, for all four detectors — and
4256
+ // demonstrated the silent pass the exclusion was leaving open: a commented-out
4257
+ // value in the `## Project Context` table makes `parseContextLine` return the
4258
+ // comment text itself as the live value, `configure-agents` writes that into
4259
+ // generated prose, and a reader sees an empty cell. Once the reason for a boundary
4260
+ // is falsified the boundary goes, rather than the reason being rewritten to fit.
4261
+ const GUIDE_UNCERTAINTY_AFFECTS = [
4262
+ 'the `## Project Context` values (`Tech stack`, `Migration / legacy context`) that `dflow configure-agents` reads back when it regenerates guide prose',
4263
+ 'whether the `## Project Context` section is found at all, and the drift report about its rows',
4264
+ // ⚠ ADDED after `p084-xv1` measured the omission. The dangling-`§` check resolves
4265
+ // workflow-bundle references against THIS file's headings, via
4266
+ // `extractHeadings` -> the same classification a shape can disturb. Listing only
4267
+ // the Project Context concerns told the user the rest of the guide's checks were
4268
+ // still trustworthy, which is the precise false reassurance an `affects` list
4269
+ // exists to prevent.
4270
+ 'whether `AI-AGENT-GUIDE.md § ...` references from the workflow bundle resolve, since that check reads this file\'s headings'
4271
+ ];
4272
+ // ⚠⚠ THE `<textarea>` MITIGATION, SPELLED ONCE AND PREFIXED TO BOTH COMMENT IDS.
4273
+ // A `<textarea>` comment is reported deliberately (see the block comment above
4274
+ // `THERE IS NO TEXTAREA EXEMPTION HERE` in `doctor-checks` — grep that phrase — for
4275
+ // why the exemption was removed rather than patched a fourth time), so the *only* thing standing between the user and an edit
4276
+ // that hides their visible text is this sentence.
4277
+ // ⚠ It has to be on BOTH ids, and putting it on one was a real defect
4278
+ // (`p084-xv11` finding 1): `<textarea><!-- rule --></textarea>` on ONE line does not
4279
+ // start its line's content, so the partition correctly calls it `inline-html-comment`
4280
+ // — and that id's action says "move the comment to a line of its own at column 0",
4281
+ // which is exactly the edit that turns displayed raw text into a hidden comment.
4282
+ // The multi-line form routes to `comment-inside-container` and was safe. One shape,
4283
+ // two ids, one of them unprotected.
4284
+ // ⚠ Spelled once and shared, not copied into both strings: two copies of a rule is
4285
+ // this codebase's oldest defect class, and this rule has already been rewritten
4286
+ // three times. The first-hit warning matters because each detector emits at most one
4287
+ // finding per file: a harmless textarea hit can shadow a later, genuinely hidden hit.
4288
+ const TEXTAREA_LEAVE_IT_ALONE = '⚠ FIRST, if the cited comment sits inside a `<textarea>`: leave that line alone. A `<textarea>` holds raw text, so moving it out would hide text the reader can already see. But do NOT ignore the overall uncertainty result or treat the affected checks as trusted: Dflow reports only the first occurrence of each shape in a file, so this harmless line can precede and shadow a genuinely hidden comment with the same id. Inspect the rest of the cited file for other apparent comment openers before treating those checks as reliable. For a non-`<textarea>` occurrence: ';
4289
+ // ⚠ This scope is part of the repair contract. The markerless continuation case
4290
+ // deliberately follows the renderer Dflow ships; CommonMark-family renderers can
4291
+ // expose the apparent opener instead, so universal "the reader sees nothing" prose
4292
+ // would send some users to edit content their actual publishing path displays.
4293
+ const MARKED_COMMENT_CALIBRATION = '⚠ A markerless continuation of list-owned raw HTML is calibrated to the renderer Dflow ships (`dflow render`, powered by Marked). Another Markdown renderer may expose an escaped apparent opener there; if you publish through a different renderer, inspect its rendered output before applying this repair. ';
4294
+ const CONVENTIONS_UNCERTAINTY_DETECTORS = [
4295
+ {
4296
+ id: 'inline-html-comment',
4297
+ title: 'an HTML comment begins part-way through a line',
4298
+ locate: (content) => doctorChecks.inlineHtmlCommentLine(content),
4299
+ detail: 'Dflow classifies Markdown one line at a time, so a comment that opens mid-line is not a block to it and its contents are counted as live document text — while `dflow render` normally shows no comment text there. A rule, policy value or heading word sitting inside such a comment therefore reads as still present after it has been switched off.',
4300
+ // ⚠⚠ THIS ACTION USED TO SEND THE USER INTO AN UNDISCLOSED SILENT PASS
4301
+ // (`p084-y1` finding 1). It said "move the comment onto a line of its own",
4302
+ // full stop — and the natural way to do that inside a list item is to indent
4303
+ // it under the item, where NOTHING fires and the text is still read. The
4304
+ // column matters, so the column is now what the sentence says.
4305
+ action: TEXTAREA_LEAVE_IT_ALONE + MARKED_COMMENT_CALIBRATION + 'move the comment to a line of its own that starts at column 0, outside any CONTAINER — a list item, a block quote, or an HTML block such as `<details>`. There it opens a real HTML block of its own and its contents stop being read. Deleting it works too. ⚠ Indenting it under a list item is NOT enough: it stays inside the item, where Dflow still reads it. ⚠ And if the comment is inside an HTML block, column 0 alone is NOT enough either — you are still inside the block, and `comment-inside-container` will report it on the next run. The explainer page section for that id has the per-tag rule for leaving an HTML block.',
4306
+ affects: CONVENTIONS_VISIBILITY_AFFECTS
4307
+ },
4308
+ {
4309
+ id: 'comment-inside-container',
4310
+ title: 'an HTML comment sits inside a container whose interior Dflow does not parse, where its text is still read',
4311
+ // ⚠ REPLACES `unclosed-html-in-container`, which asked the wrong question.
4312
+ // That one fired on an UNTERMINATED comment the document-level scan had
4313
+ // missed; measured, any later `-->` in the file silenced it — including the
4314
+ // one `unclosed-html-block`'s own action tells the user to add — while the
4315
+ // hidden rule stayed hidden. Termination was never the issue: a properly
4316
+ // closed comment inside a list item hides its text from a reader just as
4317
+ // completely, and Dflow reads it either way.
4318
+ locate: (content) => doctorChecks.containerHtmlCommentLine(content),
4319
+ detail: 'Dflow does not parse the interior of a container as its own sequence of blocks — a list item and a block quote are the common cases, and an HTML block such as `<details>` behaves the same way. A comment opened inside one therefore never opens an HTML block as far as Dflow is concerned: `dflow render` normally shows no comment text there, while Dflow goes on counting the comment\'s contents as live document text. This holds whether or not the comment is closed, and whatever the container turns out to be.',
4320
+ // ⚠⚠ THE HTML-BLOCK HALF NAMES THE TAG, and a blanket rule here was wrong for
4321
+ // two of them (`p084-xv3`). "A blank line ends the block, a closing tag does
4322
+ // not" is CommonMark's TYPE-6 rule. `<pre>` and `<textarea>` are type 1: they
4323
+ // end at their own closing tag, and they reach this id — measured, a comment
4324
+ // inside `<pre>` reports, adding the blank line the old sentence prescribed
4325
+ // does NOT clear it, and moving below `</pre>` (which the old sentence said
4326
+ // would not work) does. So the shipped repair was exactly backwards for them.
4327
+ // `<script>` and `<style>` are type 1 too but never reach here — their
4328
+ // interiors are already invisible, so nothing is hidden from a reader that was
4329
+ // not hidden anyway — which is why they are not named.
4330
+ // ⚠⚠ `<textarea>` WAS named here as a repair case for one round and that was
4331
+ // wrong twice over (`p084-xv4` finding 1): it holds raw text, so the comment is
4332
+ // displayed and there is nothing to disclose, and the advice given for it —
4333
+ // move below the closing tag — is the one edit that would genuinely hide it.
4334
+ // ⚠⚠⚠ EVERY `<textarea>` COMMENT IS NOW REPORTED, and the leading clause in the
4335
+ // action is the whole mitigation. Three rounds were spent suppressing that false
4336
+ // positive and each attempt produced a SILENT PASS instead (`p084-xv5`, `xv9`,
4337
+ // `xv10`); the exemption was removed rather than patched a fourth time. The full
4338
+ // account is under `THERE IS NO TEXTAREA EXEMPTION HERE` in `doctor-checks`.
4339
+ // Reporting is the NOISY direction, which the maintainer twice chose in this same
4340
+ // area (2026-08-09). **Do not "tidy" the `<textarea>` clause away** — without it
4341
+ // the finding sends a user to make the one edit that genuinely hides their text.
4342
+ // ⚠ The outermost-container sentence is not decoration either: with a `<pre>`
4343
+ // inside a list item, applying only the `<pre>` rule leaves the comment in the
4344
+ // list item and the finding fires again on the next run.
4345
+ // ⚠ Concatenation, not a template literal: this string carries backticked code
4346
+ // spans (`<pre>`, `>`), which end a template literal on the spot.
4347
+ action: TEXTAREA_LEAVE_IT_ALONE + MARKED_COMMENT_CALIBRATION + 'move the comment out of the enclosing container — or delete it. Leaving the container is the whole repair. ⚠ When the comment sits inside MORE THAN ONE container — a `<pre>` inside a list item, a `<details>` inside a block quote — the OUTERMOST one is the one you have to leave: repair only the inner one and the finding comes back unchanged. For a list item or block quote, put the comment on a line of its own at column 0 with no list marker or `>` before it. For an HTML block the rule depends on the tag — `<pre>` ends at its own `</pre>`, so move the comment below that; every other block (`<details>`, `<div>`, …) ends at a BLANK LINE and not at a closing tag, so put a blank line between the block and the comment, or move the comment above the block entirely. Re-indenting the COMMENT inside the container never helps. (One thing that does help, and only for the fenced-example case the explainer page describes: un-indenting the FENCE itself to three spaces or fewer.)',
4348
+ affects: CONVENTIONS_VISIBILITY_AFFECTS
4349
+ },
4350
+ {
4351
+ id: 'html-block-type-7',
4352
+ title: 'a bare custom tag stands at the start of a block, directly above a `---` or `===` line',
4353
+ locate: (content) => doctorChecks.htmlBlockType7Line(content),
4354
+ detail: 'A complete tag whose name is not one of CommonMark\'s known block tags, alone on a line, is HTML block type 7 — the only type that cannot interrupt a paragraph, and the only one that needs a real tag parser to recognise. Dflow does not implement it, so where this shape meets a following underline it ends the section earlier than a renderer does. This one is usually loud — it reports drift that is not there — but it is NOT only loud: ending the section early also drops the tail of it, so a retired row below the shape stops being seen and its finding disappears. Treat results about that section as unknown in both directions.',
4355
+ action: 'Put a blank line between the tag and the underline, or fence the tag as an example if it is being shown rather than used.',
4356
+ affects: CONVENTIONS_BOUNDARY_AFFECTS
4357
+ }
4358
+ ];
4359
+
4360
+ // ⚠⚠ `table-delimiter-cell-count` USED TO BE THE FIFTH ENTRY HERE, AND ITS REMOVAL
4361
+ // IS A DECISION, NOT AN OVERSIGHT (user, 2026-08-12). It reported a table whose
4362
+ // delimiter row carried a different number of cells from its header, on the
4363
+ // grounds that Dflow and the renderer can then disagree about where the section
4364
+ // ends. That harm is real and is still real — it is recorded, with owner and
4365
+ // gate, as `doctor-section-boundary-arbiter` in `planning/opt-in-backlog.md`.
4366
+ // What could not be made to work was the DETECTOR. Six consecutive review rounds
4367
+ // each found a document it stayed silent on, and silence here is the direction
4368
+ // that prints `All checks passed` over a drifted file:
4369
+ // `x7` bare pair, `y3` multi-line dash, `y4` equals family, `x13`/`y5`
4370
+ // underline-shaped first prose line, `x14`/`y6` indented first prose line,
4371
+ // `y7` single-hyphen delimiter row.
4372
+ // Narrowing it to measured shapes failed five times; widening it back to every
4373
+ // mismatch failed on the sixth, because the remaining enumeration had moved into
4374
+ // the delimiter-row recogniser. And `y6` measured the same silent false clean
4375
+ // with a delimiter row whose cell count was CORRECT — so this detector's scope
4376
+ // was a strict subset of the harm and no version of it could close it.
4377
+ // ⚠ The durable fix is a different instrument, not a better shape list: `marked`
4378
+ // is already a runtime dependency, so the section boundary can be taken FROM the
4379
+ // renderer instead of guessed alongside it. That is a design change with its own
4380
+ // review, which is why this ships as a stated gap instead of a sixth repair.
4381
+ // Both explainer pages name it under "shapes that are known and deliberately not
4382
+ // reported", beside the table-indent gap it now sits next to.
4383
+
4384
+
4385
+ // The document-level unclosed block is a condition on the whole file rather than
4386
+ // one of the narrower shape detectors. It still runs through the same two-file
4387
+ // target loop: both `_conventions.md` and `AI-AGENT-GUIDE.md` feed the classifier.
4388
+ const UNCLOSED_HTML_BLOCK_ID = 'unclosed-html-block';
4389
+
4390
+ function unclosedHtmlBlockUncertainty(rel, line, affects) {
4391
+ return uncertainFinding({
4392
+ id: UNCLOSED_HTML_BLOCK_ID,
4393
+ title: `${rel} has an unclosed HTML block at line ${line}`,
4394
+ // BOTH DIRECTIONS: text below the opener can produce artefact findings, and
4395
+ // genuine drift can disappear because the checks no longer see that text.
4396
+ detail: `Everything from that line to the end of the file is inside that block, so it is not ordinary document content and Dflow cannot assess results below it. This cuts both ways: a finding below this one may be caused by the unclosed block rather than by real drift, AND a rule that genuinely has drifted below it can go unreported entirely, because the block hides the text the check would have read. Treat every result about content below this line as unknown — not as passing.`,
4397
+ affects,
4398
+ action: 'Close the block (for an HTML comment, add `-->`), then re-run `dflow doctor`.'
4399
+ });
4400
+ }
4401
+
4402
+ // ⚠ Every id doctor can print, in one place, because each one is a PROMISE that
4403
+ // `docs/doctor-uncertainty.md` / `.en.md` carries a section explaining that
4404
+ // shape. A shipped message pointing at a page section that does not exist is
4405
+ // precisely the failure this proposal was created to retire — the durable fix was
4406
+ // moving the per-container explanation onto a page that can be revised, and that
4407
+ // only works while the id and the section agree. `test/upgrade-drift.mjs` asserts
4408
+ // the correspondence in both directions against both languages.
4409
+ // PROPOSAL-092 D4: a spec doc whose `<!-- dflow-shape: … -->` line cannot be read.
4410
+ // Emitted by `checkDocumentShapes`, not by the two-file loop below.
4411
+ const SHAPE_MARKER_UNREADABLE_ID = 'unreadable-shape-marker';
4412
+
4413
+ const DOCTOR_UNCERTAINTY_IDS = [
4414
+ UNCLOSED_HTML_BLOCK_ID,
4415
+ ...CONVENTIONS_UNCERTAINTY_DETECTORS.map((d) => d.id),
4416
+ SHAPE_MARKER_UNREADABLE_ID
4417
+ ];
4418
+
4419
+ // PROPOSAL-084: report the shapes this module is known to read unreliably, so a
4420
+ // project containing one never receives the same `All checks passed` a genuinely
4421
+ // clean project gets. Reads the same guarded content the sibling checks read, so
4422
+ // an absent or blank file stays the drift check's single finding rather than
4423
+ // picking up four more (the coupling described above `readConventionsForCheck`).
4424
+ async function checkConventionsParserUncertainty(cwd, findings) {
4425
+ // ⚠⚠ PUSH ORDER HERE DOES NOT DECIDE PRINT ORDER, and two review rounds reached
4426
+ // OPPOSITE conclusions about that, so the measurement is recorded rather than
4427
+ // the argument. `unclosed-html-block` moved into this loop, and the comment that
4428
+ // moved with it claimed the finding had to be emitted BEFORE the drift findings
4429
+ // "because it changes what they mean". `p084gate-y5` read the relocation as
4430
+ // having reversed a deliberate contract and filed it as a must-fix regression;
4431
+ // `p084gate-y4` had already called the same deletion a correction.
4432
+ // Measured 2026-08-12 on a project carrying BOTH a retired rule row and an
4433
+ // unclosed block, run through the real CLI on this tree and on the tree before
4434
+ // this batch: **identical output order in both** — `[warn]` first, `[uncertain]`
4435
+ // fourth lines later. `runDoctor` groups by category and prints assessed before
4436
+ // uncertain regardless of the order anything is pushed here, and did so before
4437
+ // this batch too. So the contract the old comment stated was never honoured by
4438
+ // the printer, the relocation changed nothing about it, and there is no
4439
+ // regression to repair. If you want that ordering, it has to be implemented in
4440
+ // the printer — a comment here cannot buy it.
4441
+ //
4442
+ // ⚠ Same detectors, two files, DIFFERENT `affects` — the shape is a property of
4443
+ // the Markdown, but which checks it blinds is a property of the file. Sharing
4444
+ // one list across both would put a claim about `_conventions.md`'s drift
4445
+ // fingerprints into a finding about the guide.
4446
+ const targets = [
4447
+ {
4448
+ rel: 'dflow/specs/shared/_conventions.md',
4449
+ content: await readConventionsForCheck(cwd),
4450
+ affects: null // per-detector; the conventions lists distinguish visibility from boundary
4451
+ },
4452
+ {
4453
+ rel: AI_AGENT_GUIDE_DEST,
4454
+ content: (await fs.readFile(path.join(cwd, AI_AGENT_GUIDE_DEST), 'utf8').catch(() => '')) || null,
4455
+ affects: GUIDE_UNCERTAINTY_AFFECTS
4456
+ }
4457
+ ];
4458
+ for (const target of targets) {
4459
+ if (target.content === null || !target.content.trim()) continue;
4460
+ const unclosedAt = doctorChecks.unclosedHtmlBlockLine(target.content);
4461
+ if (unclosedAt !== -1) {
4462
+ findings.push(unclosedHtmlBlockUncertainty(
4463
+ target.rel,
4464
+ unclosedAt,
4465
+ target.affects || CONVENTIONS_VISIBILITY_AFFECTS
4466
+ ));
4467
+ }
4468
+ for (const detector of CONVENTIONS_UNCERTAINTY_DETECTORS) {
4469
+ const line = detector.locate(target.content);
4470
+ if (line === -1) continue;
4471
+ findings.push(uncertainFinding({
4472
+ id: detector.id,
4473
+ title: `${target.rel}, line ${line}: ${detector.title}`,
4474
+ detail: detector.detail,
4475
+ affects: target.affects || detector.affects,
4476
+ action: detector.action
4477
+ }));
4478
+ }
4479
+ }
4480
+ }
4481
+
4482
+ async function checkConventionsDflowVersion(cwd, findings) {
4483
+ const content = await readConventionsForCheck(cwd);
4484
+ if (content === null) return;
4485
+ if (!/^> Dflow Version:/m.test(content)) {
3228
4486
  findings.push({
3229
4487
  level: 'info',
3230
4488
  title: 'dflow/specs/shared/_conventions.md missing Dflow Version line',
@@ -3234,6 +4492,1922 @@ async function checkConventionsDflowVersion(cwd, findings) {
3234
4492
  }
3235
4493
  }
3236
4494
 
4495
+ // PROPOSAL-058 (user decision 2026-06-08, OQ2): `> Dflow Version:` records the
4496
+ // Dflow version this project last reconciled with (`dflow configure-agents`
4497
+ // advances it). Behind the CLI means the Dflow-managed layers may be stale and
4498
+ // the user-owned layers unreviewed since the upgrade.
4499
+ async function checkConventionsVersionReconciled(cwd, findings) {
4500
+ const content = await readConventionsForCheck(cwd);
4501
+ if (content === null) return;
4502
+ const match = content.match(/^> Dflow Version:[ \t]*(.*)$/m);
4503
+ if (!match) return; // absence is checkConventionsDflowVersion's finding
4504
+ const recorded = match[1].trim();
4505
+ // Prerelease suffixes are valid package versions (the smoke test's own
4506
+ // Dflow-Version assertion allows them), so they must not read as "not a
4507
+ // version" noise on a fresh init of a prerelease build.
4508
+ if (!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(recorded)) {
4509
+ findings.push({
4510
+ level: 'info',
4511
+ title: `_conventions.md Dflow Version line is not a plain x.y.z version: \`${recorded}\``,
4512
+ detail: 'The line records which Dflow version the project last reconciled with; doctor cannot compare this value against the CLI.',
4513
+ action: `Set it to the Dflow version the project is actually aligned to; \`dflow configure-agents\` keeps it current from then on (CLI is ${pkg.version}).`
4514
+ });
4515
+ return;
4516
+ }
4517
+ if (recorded === pkg.version) return;
4518
+ if (compareVersions(recorded, pkg.version) < 0) {
4519
+ findings.push({
4520
+ level: 'info',
4521
+ title: `Project last reconciled with Dflow ${recorded}; this CLI is ${pkg.version}`,
4522
+ detail: 'Dflow-managed layers (workflow bundle, guide canonical content, adapters) may be stale, and user-owned layers may need review against the newer version.',
4523
+ action: 'Run `dflow configure-agents` to re-project the Dflow-managed layers and update the line, then review the upgrade guide for user-owned surfaces: https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/upgrading.en.md (offline copy: docs/upgrading.en.md in the installed package).'
4524
+ });
4525
+ } else {
4526
+ findings.push({
4527
+ level: 'info',
4528
+ title: `Project was reconciled with Dflow ${recorded}, newer than this CLI (${pkg.version})`,
4529
+ detail: 'Running an older CLI against a newer project layout can re-project older content over newer files.',
4530
+ action: 'Upgrade the dflow package before re-running `dflow init` / `dflow configure-agents` here.'
4531
+ });
4532
+ }
4533
+ }
4534
+
4535
+ // PROPOSAL-058 direction 2 (b): the policy sections are machine-read — context
4536
+ // inference for configure-agents parses the exact line formats in
4537
+ // lib/doctor-checks.js. A section that drifted from the canonical format makes
4538
+ // inference return null, and code paths that need a policy default a null to
4539
+ // `trunk` / `none`, so drift here risks a silent policy flip on any future
4540
+ // re-projection that writes these sections.
4541
+ async function checkConventionsPolicyFormat(cwd, findings) {
4542
+ const content = await readConventionsForCheck(cwd);
4543
+ if (content === null) return;
4544
+
4545
+ const sections = [
4546
+ {
4547
+ heading: '## Git Policy',
4548
+ key: 'Git Policy',
4549
+ re: doctorChecks.GIT_POLICY_LINE_RE,
4550
+ values: doctorChecks.GIT_POLICY_VALUES,
4551
+ example: 'Selected Git policy: `gitflow`',
4552
+ // ⚠ "defaults to `trunk` wherever a policy value is required" was too
4553
+ // broad, and the gate-1 disclosure put the overstatement next to its own
4554
+ // contradiction in the same report. Verified by running both commands:
4555
+ // `init` does fall back to trunk (`buildFilePlan`), but `configure-agents`
4556
+ // does NOT — `addGitPrinciplesItem` reports the missing section and
4557
+ // returns without touching any Git principles file. Say which is which.
4558
+ nullEffect: 'a null Git policy falls back to `trunk` where init needs a value, while `dflow configure-agents` instead declines to refresh `Git-principles-*.md` at all'
4559
+ },
4560
+ {
4561
+ heading: '## AI Commit Policy',
4562
+ key: 'AI Commit Policy',
4563
+ re: doctorChecks.AI_COMMIT_MARKER_LINE_RE,
4564
+ values: doctorChecks.AI_COMMIT_MARKER_VALUES,
4565
+ example: 'AI commit marker: `none`',
4566
+ nullEffect: 'an unrecognized marker value falls back to `none`'
4567
+ },
4568
+ {
4569
+ heading: '## Prose Language',
4570
+ key: 'Prose Language',
4571
+ re: doctorChecks.PROSE_LANGUAGE_LINE_RE,
4572
+ values: null,
4573
+ example: 'Project prose language: `en`',
4574
+ nullEffect: 'prose-generating flows lose the project language setting'
4575
+ }
4576
+ ];
4577
+
4578
+ // Locate the section through the SAME recognizer the drift check uses, not a
4579
+ // bespoke `^## X$`. That regex demanded an unindented heading of exactly
4580
+ // level 2, while `doctor-checks.headingAt` follows CommonMark and accepts a
4581
+ // 1-3 space indent — so ` ## Git Policy` was reported missing by this check
4582
+ // and found by the other, on a file whose policy line `parseContextLine` read
4583
+ // perfectly well (`p082-b3-g1` finding 2).
4584
+ //
4585
+ // ⚠ This deliberately WIDENS what counts as present, including to other
4586
+ // heading levels, and that is not a loosening of the contract being enforced:
4587
+ // inference (`parseContextLine`) is a whole-file match that never looks at the
4588
+ // heading at all. The heading test is only a locator for "is this section
4589
+ // here", so demanding a level and an indent was stricter than the thing it
4590
+ // protects — it produced false "missing" reports, not extra safety.
4591
+ for (const section of sections) {
4592
+ if (doctorChecks.conventionsSectionBodies(content, section.key).length === 0) {
4593
+ findings.push({
4594
+ level: 'warn',
4595
+ title: `_conventions.md is missing the ${section.heading} section`,
4596
+ detail: 'Newer Dflow init projects always carry it; existing projects are not auto-migrated (user-owned file).',
4597
+ action: `Copy the section from a fresh \`dflow init\` project and set your value (canonical line: \`${section.example}\`).`
4598
+ });
4599
+ continue;
4600
+ }
4601
+ const value = doctorChecks.parseContextLine(content, section.re);
4602
+ if (value === null || (section.values && !section.values.has(value))) {
4603
+ findings.push({
4604
+ level: 'warn',
4605
+ title: `_conventions.md ${section.heading} line is not machine-readable`,
4606
+ detail: `Dflow parses a \`${section.example}\`-style line to infer project context; as written, inference returns null and ${section.nullEffect}.`,
4607
+ action: `Restore the canonical line format, e.g. \`${section.example}\`.`
4608
+ });
4609
+ }
4610
+ }
4611
+ }
4612
+
4613
+ // PROPOSAL-058 direction 2 (a): guide manageability + canonical staleness. The
4614
+ // canonical region is substitution-free by design (a test guards that), so a
4615
+ // current projection equals the packaged region byte-for-byte after LF
4616
+ // normalization.
4617
+ async function checkGuideCanonicalState(cwd, findings) {
4618
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
4619
+ if (!(await pathExists(guidePath))) {
4620
+ if (await pathExists(path.join(cwd, WORKFLOW_BUNDLE_DEST))) {
4621
+ findings.push({
4622
+ level: 'warn',
4623
+ title: `${AI_AGENT_GUIDE_DEST} is missing but the workflow bundle is projected`,
4624
+ detail: 'Bundle flow files § reference the guide; without it agents lose routing, ceremony, and transparency rules.',
4625
+ action: 'Run `dflow configure-agents` to project the guide.'
4626
+ });
4627
+ }
4628
+ return;
4629
+ }
4630
+ // ⚠⚠ PRESENT-BUT-UNREADABLE IS NOT EMPTY, AND THE OLD `.catch(() => '')` MADE
4631
+ // IT SO. An empty string classifies as `absent` markers and then fails the
4632
+ // recognizable-guide test, so a guide that is perfectly current — but locked
4633
+ // by permissions, or replaced by a directory — was reported as "not
4634
+ // recognizable as a Dflow guide", a verdict on bytes this function never read,
4635
+ // with an action telling the reader to REBUILD an intact file. The canonical
4636
+ // comparison also vanished in the same breath, unannounced.
4637
+ // ⚠ The remedy is already written twice in this file — `checkInitOnlyStarters`
4638
+ // splits `unreadable` out for this exact reason, and so does
4639
+ // `checkAdapterAndSkillState`. This was the third site, left as it was.
4640
+ let rawGuide = null;
4641
+ let guideReadError = null;
4642
+ try {
4643
+ rawGuide = await fs.readFile(guidePath, 'utf8');
4644
+ } catch (error) {
4645
+ guideReadError = error;
4646
+ }
4647
+ if (rawGuide === null) {
4648
+ findings.push({
4649
+ level: 'warn',
4650
+ title: `${AI_AGENT_GUIDE_DEST} could not be read`,
4651
+ detail: `It exists but this check could not read it (${guideReadError && (guideReadError.code || guideReadError.message)}), so nothing can be said about its markers or whether its canonical region is current.`,
4652
+ action: 'Check the path\'s permissions and that it is a file rather than a directory, then re-run `dflow doctor`.'
4653
+ });
4654
+ return;
4655
+ }
4656
+ const content = toLf(rawGuide);
4657
+ const region = classifyMarkedRegion(content, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
4658
+ if (region.state === 'malformed') {
4659
+ findings.push({
4660
+ level: 'warn',
4661
+ title: `${AI_AGENT_GUIDE_DEST} has malformed guide-canonical markers`,
4662
+ detail: '`dflow configure-agents` cannot locate the canonical region and will not refresh it.',
4663
+ action: 'Repair or remove the stray `<!-- dflow-generated: guide-canonical ... -->` markers, then re-run `dflow configure-agents`.'
4664
+ });
4665
+ return;
4666
+ }
4667
+ if (region.state === 'absent') {
4668
+ // Mirror the configure-agents bootstrap split: the adoption offer only
4669
+ // exists for a recognizable Dflow guide, so pointing an unrecognizable file
4670
+ // at the offer would be impossible advice.
4671
+ if (isRecognizableDflowGuide(content)) {
4672
+ findings.push({
4673
+ level: 'info',
4674
+ title: `${AI_AGENT_GUIDE_DEST} predates managed guide-canonical markers`,
4675
+ detail: 'Its canonical sections stay at the Dflow version that wrote them; upgrades cannot refresh them in place.',
4676
+ action: 'Re-run `dflow configure-agents` on an interactive terminal and accept the marker-adoption offer (your "## Project Context" is kept), or reconcile manually against a fresh `dflow init`.'
4677
+ });
4678
+ } else {
4679
+ findings.push({
4680
+ level: 'info',
4681
+ title: `${AI_AGENT_GUIDE_DEST} is not recognizable as a Dflow guide`,
4682
+ detail: 'It has no guide-canonical markers and lacks the Dflow guide shape (`# Dflow AI Agent Guide` title plus `## Project Context`), so `configure-agents` will not offer marker adoption and workflow-bundle § references may dangle.',
4683
+ action: 'If it should be Dflow-managed, rebuild it from a fresh `dflow init` comparison (carry your project notes over), or keep maintaining it yourself.'
4684
+ });
4685
+ }
4686
+ return;
4687
+ }
4688
+ // ⚠⚠ PROPOSAL-091 gates 4, 5 and 6 — this function carried all three of the
4689
+ // shapes `checkInitOnlyStarters`'s comment had already named and removed next
4690
+ // door, untouched.
4691
+ //
4692
+ // GATE 4 (`if (!edition) return`) — route (B). The project's own canonical
4693
+ // region is bytes this function already holds; an unknown edition says nothing
4694
+ // about whether those bytes are stale. The edition set is closed, so compare
4695
+ // against every track and report only when the region matches none.
4696
+ // GATE 5 (`catch { return }`) and GATE 6 (packaged region absent) — route (A),
4697
+ // and both must report. A packaged guide that cannot be read, or that carries
4698
+ // no canonical markers, is a damaged INSTALL. Reported with the same title as
4699
+ // the sibling package findings so one broken package reads as one problem.
4700
+ const edition = await inferProjectBundleEdition(cwd);
4701
+ const candidateEditions = edition ? [edition] : [...BUNDLE_EDITIONS];
4702
+ const usable = [];
4703
+ const damaged = [];
4704
+ for (const candidate of candidateEditions) {
4705
+ let packaged = null;
4706
+ let readError = null;
4707
+ try {
4708
+ // ⚠ `toLf` BEFORE CLASSIFYING, exactly as `checkInitOnlyStarters` does one
4709
+ // function away. Without it a CRLF checkout of the package (npm link with
4710
+ // `core.autocrlf=true`) had its markers masked away by the code-block
4711
+ // masker, and doctor reported an intact package as "carries no well-formed
4712
+ // guide-canonical markers" — a false statement about the packaged file.
4713
+ // It also keeps the byte comparison below honest: the project side is
4714
+ // normalized, so the packaged side has to be too, or every CRLF install
4715
+ // reports drift that is only a line ending.
4716
+ packaged = toLf(await readPackagedTemplate(candidate, 'scaffolding/AI-AGENT-GUIDE.md'));
4717
+ } catch (error) {
4718
+ readError = error;
4719
+ }
4720
+ const packagedRegion = packaged === null
4721
+ ? null
4722
+ : classifyMarkedRegion(packaged, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
4723
+ if (packagedRegion && packagedRegion.state === 'present') {
4724
+ usable.push({ edition: candidate, packaged, region: packagedRegion });
4725
+ } else {
4726
+ const cause = packaged === null
4727
+ ? `could not be read (${readError && readError.message ? readError.message : readError})`
4728
+ : 'carries no well-formed guide-canonical markers';
4729
+ damaged.push(`\`templates/${candidate}/scaffolding/AI-AGENT-GUIDE.md\` ${cause}`);
4730
+ }
4731
+ }
4732
+ if (damaged.length > 0) {
4733
+ const scope = edition
4734
+ ? ''
4735
+ : ' This project does not say which track it uses, so every shipped track was checked.';
4736
+ findings.push({
4737
+ level: 'warn',
4738
+ title: 'The installed dflow package looks incomplete',
4739
+ detail: `Its packaged agent guide is unusable: ${damaged.join('; ')}.${scope} This report therefore cannot say whether ${AI_AGENT_GUIDE_DEST}'s canonical region is current.`,
4740
+ action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project.'
4741
+ });
4742
+ }
4743
+ // ⚠⚠ ANY damaged candidate stops the COMPARISON, not just all of them. This
4744
+ // said `usable.length === 0` and that was a false-clean's mirror image — a
4745
+ // false DIRTY. With the edition unknown and one packaged guide unreadable, the
4746
+ // remaining track was compared alone and a pristine project guide matching the
4747
+ // UNREADABLE track was reported as drifted. The finding this function has
4748
+ // already pushed by now says, in its own words, that this report "cannot say
4749
+ // whether the canonical region is current" — and the next line said it anyway.
4750
+ //
4751
+ // The premise of the drift claim is "it matches NONE of the shipped tracks".
4752
+ // One unreadable candidate leaves that unestablished, so the honest output is
4753
+ // the package finding alone. Same evidence bar `checkInitOnlyStarters` reaches
4754
+ // by a different route: it compares only against `resolvedEdition`, because
4755
+ // naming a specific packaged file in a drift claim requires knowing which one.
4756
+ if (damaged.length > 0) return;
4757
+ const projectCanonical = content.slice(region.startIdx, region.endIdx);
4758
+ // ⚠ Matching ANY candidate clears it. With the track unknown the project could
4759
+ // legitimately be either one, so "differs from greenfield" alone is not drift.
4760
+ if (usable.some((candidate) => projectCanonical === candidate.packaged.slice(candidate.region.startIdx, candidate.region.endIdx))) return;
4761
+ findings.push({
4762
+ level: 'info',
4763
+ title: `${AI_AGENT_GUIDE_DEST} canonical content differs from this CLI version`,
4764
+ detail: usable.length === 1
4765
+ ? 'The marker-guarded canonical region does not match what this Dflow version projects.'
4766
+ : 'The marker-guarded canonical region does not match what this Dflow version projects. This project does not say which track it uses, so every shipped track was checked and the region matches none of them.',
4767
+ action: 'Run `dflow configure-agents` to refresh the canonical region in place (content outside the markers is kept).'
4768
+ });
4769
+ }
4770
+
4771
+ // PROPOSAL-076: the guide's "## Project Context" rows are the machine-readable
4772
+ // source context inference reads (tech stack / migration context). Unlike the
4773
+ // _overview.md rows the pre-076 inference looked for — which never existed in
4774
+ // any packaged template, so their absence is canonical — these rows have
4775
+ // shipped in every projected guide, so a missing or unparseable row here IS
4776
+ // drift worth reporting. Info-level only: the inference fallback
4777
+ // ('unknown'/'none') is benign, and rewriting Project Context is the user's
4778
+ // designed freedom (PROPOSAL-058 boundary).
4779
+ async function checkGuideProjectContextFormat(cwd, findings) {
4780
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
4781
+ if (!(await pathExists(guidePath))) return; // missing guide is checkGuideCanonicalState's finding
4782
+ const content = toLf(await fs.readFile(guidePath, 'utf8').catch(() => ''));
4783
+ // Judge marker-managed guides and recognizable pre-marker guides. Anything
4784
+ // else already gets its own unrecognizable / malformed finding, where
4785
+ // row-level advice would be impossible advice. Deliberately NOT only the
4786
+ // adoption predicate: a marker-managed guide whose "## Project Context" was
4787
+ // deleted has fine markers and fails recognizability, yet inference just
4788
+ // lost its source — that is a finding, not a skip (gate G5).
4789
+ const markers = classifyMarkedRegion(content, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
4790
+ if (markers.state !== 'present' && !isRecognizableDflowGuide(content)) return;
4791
+ const section = projectContextParseSlice(content);
4792
+ if (section === null) {
4793
+ // ⚠ REPORT THE CAUSE WHEN THERE IS ONE (`p082-b3-k3` gap G). An unclosed
4794
+ // `<!--` above the heading puts it inside an HTML block, so `classifyLines`
4795
+ // does not see a heading and the section reads as absent — while it is
4796
+ // sitting right there in the file. `_conventions.md` already names this
4797
+ // cause and gives the line number; the guide gave the opaque message below
4798
+ // instead, whose action ("restore the section") is impossible advice for a
4799
+ // file that already has one, and whose "ignore this if you removed it on
4800
+ // purpose" invites dismissing a real malformation. Same cause, same
4801
+ // reporting.
4802
+ // ⚠⚠ WHY THE PREDICATE IS THE SYMPTOM AND NOT `unclosedHtmlBlockLine`,
4803
+ // measured on a real init'd guide before this was written. The obvious
4804
+ // version — reuse the `_conventions.md` unclosed-block reporting, which is
4805
+ // what the review recommended — almost never fires here: `## Project
4806
+ // Context` sits ABOVE the guide-canonical markers, and those marker lines
4807
+ // contain `-->`, so a `<!--` left open above the heading is CLOSED by the
4808
+ // marker below it. The block is well-formed at EOF, `unclosedHtmlBlockLine`
4809
+ // returns -1, and the heading is still inside that block, still invisible to
4810
+ // `classifyLines`, still reported by the opaque message below. A cause check
4811
+ // that cannot fire in the shipped file's own shape is worse than none: it
4812
+ // reports nothing while looking like coverage.
4813
+ // So ask the question that actually distinguishes the two states: is the
4814
+ // heading IN the file while the parser cannot see it? Fenced examples are
4815
+ // blanked first, so a heading inside a ```md block does not count as the
4816
+ // section — this branch deliberately names no block for it.
4817
+ // ⚠ It does NOT follow that such a file returned earlier. `debt212223-y3`
4818
+ // finding 2 measured the opposite: the early return above is a CONJUNCTION,
4819
+ // `markers.state !== 'present' && !isRecognizableDflowGuide(content)`, so any
4820
+ // guide written by a current `dflow init` — markers present — runs through here
4821
+ // however unrecognizable its body is, and lands on the generic finding below.
4822
+ // The decision was right; the reason written under it was not, and in a module
4823
+ // where these comments are the contract that is the expensive kind of wrong.
4824
+ const scan = doctorChecks.blankFencedBlocks(content.replace(/^\uFEFF/, ''));
4825
+ const classified = doctorChecks.classifyLines(scan);
4826
+ // ⚠⚠ ASK `parseAtxHeading`, DO NOT WRITE A FIFTH ATX RULE (`debt212223-y1`
4827
+ // finding 1). The first version tested `/^ {0,3}##[ \t]+Project Context[ \t]*#*[ \t]*$/`,
4828
+ // which accepts `## Project Context###` — CommonMark strips a closing sequence
4829
+ // only when a space precedes it, so `parseAtxHeading` reads that line's text as
4830
+ // `Project Context###` and the file does NOT have the heading. The finding
4831
+ // claimed it did, and the repair it suggested provably did not clear it.
4832
+ // `projectContextSectionBounds`, two functions above, carries a ⚠ recording that
4833
+ // it was moved OFF hand-rolled ATX rules for exactly this: it had become a
4834
+ // fourth place deciding what a heading is, a patch behind its siblings. This
4835
+ // was the fifth. There is now one rule and it lives in `doctor-checks`.
4836
+ // ⚠ BOUNDARY, stated rather than left to be discovered: this recognises the
4837
+ // **ATX** form only. A setext-underlined `Project Context` that is hidden by an
4838
+ // HTML block falls through to the generic finding below, whose action covers the
4839
+ // hidden case in words rather than by naming the block.
4840
+ const rawHeadingAt = scan.findIndex((line) => {
4841
+ const parsed = doctorChecks.parseAtxHeading(line);
4842
+ return parsed !== null && parsed.level === 2 && parsed.text === 'Project Context';
4843
+ });
4844
+ if (rawHeadingAt !== -1 && classified[rawHeadingAt] && classified[rawHeadingAt].type === 'html') {
4845
+ // ⚠⚠ THE OPENING LINE COMES FROM THE CLASSIFIER, NOT FROM A SCAN OF OUR OWN.
4846
+ // Three consecutive review rounds each defeated a reverse-scan version of this
4847
+ // (`debt212223-xv1` F2 → `xv2` F1 → `xv3` F1): walking back over contiguous
4848
+ // `html` lines crossed into the block above; requiring a line-start `<!--`
4849
+ // still named comment- or tag-shaped lines that sit INSIDE an open block
4850
+ // (`prose <!-- y`, `<p>x</p>`), because "can this line open a block" is not a
4851
+ // property of the line — it depends on cross-line state only `classifyLines`
4852
+ // holds. Three rounds on one predicate is this repo's signal to change the
4853
+ // shape rather than add a fourth patch, so `classifyLines` now carries
4854
+ // `blockStart` and this reads it. The fallback is only for a classifier that
4855
+ // did not supply it; it cannot be reached through `type === 'html'` today.
4856
+ const openerAt = typeof classified[rawHeadingAt].blockStart === 'number'
4857
+ ? classified[rawHeadingAt].blockStart
4858
+ : rawHeadingAt;
4859
+ const openerLine = scan[openerAt].trim();
4860
+ const shownOpener = openerLine.length > 60 ? `${openerLine.slice(0, 60)}…` : openerLine;
4861
+ // ⚠⚠ ONE DECISION POINT, AND IT IS THE LINE-START RULE — not `includes('<!--')`
4862
+ // (`debt212223-xv4` finding 1). `<details><!-- note` opens a **type-6 tag**
4863
+ // block that merely contains a comment marker; advising `-->` there is advice
4864
+ // that does not work, and the reviewer confirmed by running it that the
4865
+ // heading stays inside the `<details>` afterwards and doctor repeats the same
4866
+ // finding. This is the SECOND site of this same rule in this change — the
4867
+ // opener scan was corrected for it one round earlier and this branch was left
4868
+ // behind, which is exactly the "fixed one member of the class" failure this
4869
+ // repo keeps paying for. Whoever touches either site greps for both.
4870
+ const openerIsComment = /^ {0,3}<!--/.test(scan[openerAt]);
4871
+ // ⚠⚠ IT REPORTS THE SHAPE, NOT A DIAGNOSIS, and the level matches that
4872
+ // (`debt212223-xv1` finding 1). The first version said "the section is
4873
+ // present in the file … do not add a second one" at `warn` — which is a
4874
+ // claim doctor cannot support: the same shape is produced by a comment left
4875
+ // open by accident (a live section is being swallowed) AND by a section the
4876
+ // adopter commented out on purpose (there is nothing to repair, and telling
4877
+ // them not to re-add it is telling them not to undo their own edit). It also
4878
+ // said "an HTML comment opens with `<!--` and runs until `-->`" on a block
4879
+ // that may be a `<details>`, sending `-->` advice about a tag that has none.
4880
+ // So: name the block that is actually there, quote its opening line, and let
4881
+ // the reader say which situation it is.
4882
+ findings.push({
4883
+ level: 'info',
4884
+ title: `${AI_AGENT_GUIDE_DEST} has a "## Project Context" heading at line ${rawHeadingAt + 1} that is inside an HTML block, so Dflow reads no Project Context section`,
4885
+ detail: `The block opens at line ${openerAt + 1} (\`${shownOpener}\`) and the heading sits inside it, so the heading is block content rather than a heading — \`dflow configure-agents\` falls back to \`unknown\` / \`none\` for tech stack and migration context. Two different situations produce exactly this shape and doctor cannot tell them apart: a block left open by accident is swallowing a live section, or the section was commented out deliberately.`,
4886
+ // ⚠⚠ THREE BRANCHES, BECAUSE "CLOSE IT" IS NOT ONE INSTRUCTION
4887
+ // (`debt212223-y4` finding 1). The previous version had two and told every
4888
+ // non-comment opener to "close it" — which is right for `<script>` / `<?` /
4889
+ // `<![CDATA[` and **provably useless for `<details>` and `<div>`**: a type-6
4890
+ // block ends at a BLANK LINE, so writing `</details>` above the heading
4891
+ // leaves the finding exactly as it was, with nothing telling the adopter
4892
+ // their repair was wrong. Measured both ways. And type 6 is precisely the
4893
+ // class this branch exists to serve, so it was the same "fixed one member of
4894
+ // the class" failure that `debt212223-xv4` had just closed one branch over.
4895
+ // ⚠ The deciding fact comes from `classifyLines` (`blockEndsOnBlank`), not
4896
+ // from re-inspecting the opener text here — that re-derivation is what cost
4897
+ // three earlier rounds.
4898
+ action: openerIsComment
4899
+ ? `If the comment at line ${openerAt + 1} was left open mid-edit, close it with \`-->\` above the heading and re-run \`dflow doctor\`. If the section is commented out on purpose, nothing is broken — inference will keep falling back.`
4900
+ : classified[rawHeadingAt].blockEndsOnBlank === true
4901
+ ? `A \`<details>\` / \`<div>\`-style block ends at a BLANK LINE, not at a closing tag — writing \`</...>\` above the heading will not free it. If the block at line ${openerAt + 1} is not meant to contain the heading, put a blank line between them, or move the section above the block; then re-run \`dflow doctor\`. If the heading belongs inside it, nothing is broken — inference will keep falling back.`
4902
+ : `Close the block opened at line ${openerAt + 1} with its own end marker (\`</script>\`, \`</style>\`, \`?>\`, \`]]>\`, \`>\`) above the heading, or move the section above the block; then re-run \`dflow doctor\`. If the heading belongs inside it, nothing is broken — inference will keep falling back.`
4903
+ });
4904
+ return;
4905
+ }
4906
+ findings.push({
4907
+ level: 'info',
4908
+ title: `${AI_AGENT_GUIDE_DEST} has no "## Project Context" section`,
4909
+ // ⚠ THE ACTION MUST NOT BE IMPOSSIBLE ADVICE (`debt212223-y1` finding 2). The
4910
+ // branch above names the HTML block for an ATX heading; a setext-underlined
4911
+ // `Project Context` hidden the same way reaches HERE, and telling that adopter
4912
+ // to "restore a section" they can see in their own file is the defect this
4913
+ // whole item exists to remove. So this message carries the possibility in
4914
+ // words: it costs a sentence and covers every heading syntax, including ones
4915
+ // added later.
4916
+ // ⚠ THE DETAIL STATES THE SHAPE; THE ACTION ENUMERATES (`debt212223-y4`
4917
+ // finding 2). It used to lead with "an HTML block above it — most often an
4918
+ // `<!--` left open" — a cause the branch above now takes for every ATX
4919
+ // heading, so the only file that can reach this line with that cause is one
4920
+ // with a setext-underlined heading. Naming it here pointed most readers at
4921
+ // the one thing that is not their problem.
4922
+ detail: '`dflow configure-agents` infers project context (tech stack, migration context) from that section\'s table rows; without it, inference falls back to `unknown` / `none`. Ignore this if you removed the section on purpose. ⚠ If the section IS in the file, then something is keeping that line from being read as a document-level heading — see the causes below.',
4923
+ // ⚠ NON-EXHAUSTIVE ON PURPOSE (`debt212223-xv7`). The first version said "two
4924
+ // things hide it" and named an HTML block or the heading line itself — but any
4925
+ // container the heading ends up inside does it too: an unclosed fence, a
4926
+ // blockquote, a list item, an indented code line, all reproduced. A closed
4927
+ // enumeration in adopter-facing advice is the same defect as a closed
4928
+ // enumeration in a rule: the reader who is in the (N+1)th case concludes the
4929
+ // advice does not apply to them. State the shape and give examples.
4930
+ action: 'If the section is not in the file, restore a "## Project Context" section above the guide-canonical markers, including the `| Tech stack | ... |` and `| Migration / legacy context | ... |` rows. If it IS there, then something is stopping that line from being a document-level `##` heading. Common causes: a container around it (an HTML block, an unclosed ``` fence, a blockquote, a list item, or a 4-space indent), or the heading line itself — a closing `###` with no space before it, a level other than `##`, or an invisible character in the text are all part of the heading and make it a different one.'
4931
+ });
4932
+ return;
4933
+ }
4934
+ const rows = [
4935
+ { label: 'Tech stack', re: doctorChecks.TECH_STACK_ROW_RE, example: '| Tech stack | ASP.NET Core 9, EF Core, xUnit |', fallback: '`unknown`' },
4936
+ { label: 'Migration / legacy context', re: doctorChecks.MIGRATION_CONTEXT_ROW_RE, example: '| Migration / legacy context | none |', fallback: '`none`' }
4937
+ ];
4938
+ const missing = rows.filter((row) => doctorChecks.parseContextLine(section, row.re) === null);
4939
+ if (missing.length === 0) return;
4940
+ findings.push({
4941
+ level: 'info',
4942
+ title: `${AI_AGENT_GUIDE_DEST} "## Project Context" is missing machine-readable row(s): ${missing.map((row) => row.label).join(', ')}`,
4943
+ detail: `\`dflow configure-agents\` infers project context from these table rows; as written, inference falls back to ${missing.map((row) => row.fallback).join(' / ')}. Ignore this if you rewrote the section on purpose.`,
4944
+ action: `Restore the table row format inside "## Project Context", e.g. \`${missing[0].example}\`.`
4945
+ });
4946
+ }
4947
+
4948
+ // PROPOSAL-058 direction 2 (a): dangling `AI-AGENT-GUIDE.md § Heading`
4949
+ // references — the drift class that motivated this proposal: a frozen guide plus
4950
+ // a refreshed workflow bundle leaves flow files pointing at guide sections that
4951
+ // do not exist.
4952
+ async function checkGuideSectionRefs(cwd, findings) {
4953
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
4954
+ if (!(await pathExists(guidePath))) return; // missing guide reported above
4955
+ const guideHeadings = doctorChecks.extractHeadings(await fs.readFile(guidePath, 'utf8').catch(() => ''));
4956
+ if (guideHeadings.length === 0) return;
4957
+
4958
+ const scanFiles = [{ rel: 'dflow/specs/shared/_conventions.md', abs: path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md') }];
4959
+ for (const dir of ['references', 'templates']) {
4960
+ const absoluteDir = path.join(cwd, WORKFLOW_BUNDLE_DEST, dir);
4961
+ let entries = [];
4962
+ try {
4963
+ entries = await fs.readdir(absoluteDir);
4964
+ } catch {
4965
+ continue;
4966
+ }
4967
+ for (const entry of entries) {
4968
+ if (entry.endsWith('.md')) {
4969
+ scanFiles.push({ rel: `${WORKFLOW_BUNDLE_DEST}/${dir}/${entry}`, abs: path.join(absoluteDir, entry) });
4970
+ }
4971
+ }
4972
+ }
4973
+
4974
+ const dangling = [];
4975
+ for (const file of scanFiles) {
4976
+ const content = await fs.readFile(file.abs, 'utf8').catch(() => null);
4977
+ if (content === null) continue;
4978
+ for (const ref of doctorChecks.extractSectionRefs(content, 'AI-AGENT-GUIDE.md')) {
4979
+ if (!doctorChecks.headingResolves(ref.headingText, guideHeadings)) {
4980
+ dangling.push(`${file.rel}:${ref.line} § "${ref.headingText}"`);
4981
+ }
4982
+ }
4983
+ }
4984
+ if (dangling.length === 0) return;
4985
+ const shown = dangling.slice(0, 8);
4986
+ const more = dangling.length - shown.length;
4987
+ findings.push({
4988
+ level: 'warn',
4989
+ title: `Dangling AI-AGENT-GUIDE.md § reference(s): ${dangling.length}`,
4990
+ detail: `${shown.join('; ')}${more > 0 ? `; +${more} more` : ''} — the guide has no matching heading, usually because it is frozen at an older Dflow version than the workflow bundle.`,
4991
+ action: 'Refresh the guide canonical content with `dflow configure-agents` (accept marker adoption if offered), or align the guide manually.'
4992
+ });
4993
+ }
4994
+
4995
+ // PROPOSAL-058 direction 2 (d), user decision 2026-06-08 OQ5: init-only starters
4996
+ // are user-owned and never re-projected — doctor only reports, never rewrites.
4997
+ async function checkInitOnlyStarters(cwd, findings) {
4998
+ const conventions = await fs.readFile(path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md'), 'utf8').catch(() => '');
4999
+ const parsedPolicy = doctorChecks.parseContextLine(conventions, doctorChecks.GIT_POLICY_LINE_RE);
5000
+ const recordedPolicy = parsedPolicy && doctorChecks.GIT_POLICY_VALUES.has(parsedPolicy) ? parsedPolicy : null;
5001
+ const edition = await inferProjectBundleEdition(cwd);
5002
+
5003
+ // ⚠⚠ PROPOSAL-091 gate 1 — route (B), with (A) as the fallback. `if (policy)`
5004
+ // with no else was the seventh boundary drawn around a package check in this
5005
+ // file, and it was the widest: a project with no `## Git Policy` section lost
5006
+ // the WHOLE Git-principles block — presence and drift both — while the only
5007
+ // thing doctor said was a separate warn about the missing section that never
5008
+ // mentioned what it had switched off. That is the same lesson as the comment
5009
+ // below: the policy value decides WHICH starter to compare against, and that
5010
+ // is a fact about the project's shape. Which `Git-principles-*.md` files the
5011
+ // project actually HAS is visible on disk, so when the value is missing we
5012
+ // check every one of them instead of checking none.
5013
+ //
5014
+ // ⚠ The absent value really does stop one thing, and only one: with no policy
5015
+ // recorded doctor cannot say a needed file is MISSING, because it does not
5016
+ // know which one was needed. The loop below only ever visits policies whose
5017
+ // file is present, so that branch is unreachable on this path by construction
5018
+ // rather than by wording.
5019
+ const presentPolicies = [];
5020
+ for (const candidate of doctorChecks.GIT_POLICY_VALUES) {
5021
+ if (await pathExists(path.join(cwd, 'dflow', 'specs', 'shared', `Git-principles-${candidate}.md`))) {
5022
+ presentPolicies.push(candidate);
5023
+ }
5024
+ }
5025
+ const policiesToCheck = recordedPolicy ? [recordedPolicy] : presentPolicies;
5026
+
5027
+ for (const policy of policiesToCheck) {
5028
+ const relativePath = `dflow/specs/shared/Git-principles-${policy}.md`;
5029
+ const absolute = path.join(cwd, relativePath);
5030
+ const projectFileExists = await pathExists(absolute);
5031
+ // ⚠⚠ WITHOUT A RECORDED POLICY, `configure-agents` REFUSES TO TOUCH ANY OF
5032
+ // THESE FILES — `addGitPrinciplesItem` reports it and returns before writing
5033
+ // a byte. So every action below that sends the reader there has to say what
5034
+ // to do first. Promising a refresh the sibling command will decline is the
5035
+ // false-claim-about-another-command class this check has already corrected
5036
+ // twice (`p090-b3-z1`), and route (B) would otherwise re-introduce it at the
5037
+ // exact boundary this proposal exists to be honest about.
5038
+ // ⚠ Name THIS file's policy, not a fixed example. The loop is here because
5039
+ // `Git-principles-<policy>.md` is on disk, so the value to restore is known
5040
+ // — and a hardcoded `gitflow` in a finding about a trunk project reads as an
5041
+ // instruction, which would have the reader write the wrong policy into their
5042
+ // conventions. The sibling strings nearby hedge with "e.g." or "or `trunk`"
5043
+ // precisely because they cannot know; this one can.
5044
+ const restorePolicyFirst = recordedPolicy
5045
+ ? ''
5046
+ : `Restore the \`## Git Policy\` section of \`dflow/specs/shared/_conventions.md\` first — this file is the \`${policy}\` starter, so the canonical line is \`Selected Git policy: \`${policy}\`\`. While the section is missing, \`dflow configure-agents\` declines to refresh any Git principles file. Then: `;
5047
+
5048
+ // ⚠⚠ WHICH TRACKS COULD `configure-agents` READ? THE EDITION SET IS CLOSED,
5049
+ // SO WHEN THE PROJECT CANNOT TELL US, CHECK THEM ALL. This is the fifth
5050
+ // boundary guessed around a package check in this file and the fourth one
5051
+ // that shipped as a false clean; the previous four are recorded on
5052
+ // `checkWorkflowBundleSourceAndOrphans`, which reached exactly this shape
5053
+ // and wrote down why. Each earlier guess here was narrower than the last —
5054
+ // "the project file exists" (`p090-b3-x1r`), then "an edition is inferable"
5055
+ // (`p090-b3-x2r`) — and each was reported as the fix.
5056
+ // `p090-b3-x2r` executed it: strip the manifest and every structural signal
5057
+ // from a `trunk` project, break BOTH packaged trunk starters, and doctor
5058
+ // printed `All checks passed` while `configure-agents`, having asked the
5059
+ // user which track to use, died on
5060
+ // `Internal error: packaged Git-principles-trunk.md has no well-formed
5061
+ // git-principles-canonical markers.`
5062
+ //
5063
+ // ⚠ The two resolvers do not agree in general — this check prefers the
5064
+ // manifest, `configure-agents` resolves by structure — so a single track is
5065
+ // assumed ONLY when both name the same one (`projgate-sol1`). Otherwise
5066
+ // every track the selected policy could resolve to is a candidate.
5067
+ const structureEdition = await inferExistingEdition(cwd);
5068
+ const resolvedEdition = edition && edition === structureEdition ? edition : null;
5069
+ const candidateEditions = resolvedEdition ? [resolvedEdition] : [...BUNDLE_EDITIONS];
5070
+
5071
+ const packagedByEdition = new Map();
5072
+ for (const candidate of candidateEditions) {
5073
+ let candidateTemplate = null;
5074
+ let candidateError = null;
5075
+ try {
5076
+ candidateTemplate = await readPackagedTemplate(candidate, `scaffolding/Git-principles-${policy}.md`);
5077
+ } catch (error) {
5078
+ candidateError = error;
5079
+ }
5080
+ // ⚠ An UNREADABLE packaged starter belongs here too, not one branch
5081
+ // earlier: the old `catch { template = null; }` plus `if (template)` made
5082
+ // a missing packaged file the quietest state of all. Same damage, same
5083
+ // remedy, so it is one finding with the cause named.
5084
+ const candidateRegion = candidateTemplate === null
5085
+ ? null
5086
+ : classifyMarkedRegion(
5087
+ toLf(candidateTemplate),
5088
+ GIT_PRINCIPLES_CANONICAL_START,
5089
+ GIT_PRINCIPLES_CANONICAL_END
5090
+ );
5091
+ packagedByEdition.set(candidate, {
5092
+ template: candidateTemplate,
5093
+ error: candidateError,
5094
+ region: candidateRegion,
5095
+ usable: candidateRegion !== null && candidateRegion.state === 'present'
5096
+ });
5097
+ }
5098
+
5099
+ const damagedEditions = candidateEditions.filter((candidate) => !packagedByEdition.get(candidate).usable);
5100
+ if (damagedEditions.length > 0) {
5101
+ // ⚠ Say the decisive thing only when it is true. `configure-agents` is
5102
+ // certain to fail when EVERY track it could resolve to is damaged — which
5103
+ // covers both "we know the track and it is broken" and "we do not know the
5104
+ // track but all of them are broken". With only some damaged, the reader is
5105
+ // told what is actually known instead of what sounds decisive.
5106
+ const certain = damagedEditions.length === candidateEditions.length;
5107
+ const causes = damagedEditions.map((candidate) => {
5108
+ const entry = packagedByEdition.get(candidate);
5109
+ const cause = entry.region === null
5110
+ ? `could not be read (${entry.error && entry.error.message ? entry.error.message : entry.error})`
5111
+ : 'carries no well-formed git-principles-canonical markers';
5112
+ return `\`templates/${candidate}/scaffolding/Git-principles-${policy}.md\` ${cause}`;
5113
+ });
5114
+ const scope = resolvedEdition
5115
+ ? ''
5116
+ : ' This project does not say which track it uses, so every track the selected policy could resolve to was checked.';
5117
+ // ⚠ THE THIRD SPELLING OF `alsoFails`, AND ROUTE (B) IS WHY. Both of the
5118
+ // originals assume `configure-agents` will get as far as reading this
5119
+ // starter — true only while a policy IS recorded. With none recorded it
5120
+ // never gets there: `addGitPrinciplesItem` reports the missing section and
5121
+ // returns before touching the package, so "fails on this package" would be
5122
+ // a confident false statement about the sibling command on the one path
5123
+ // this gate newly reaches.
5124
+ const alsoFails = !recordedPolicy
5125
+ ? '`dflow configure-agents` does not reach it either way: with no Git policy recorded it declines to refresh any Git principles file.'
5126
+ : (certain
5127
+ ? '`dflow configure-agents` fails on this package before writing a byte.'
5128
+ : 'Whether `dflow configure-agents` also fails depends on which track it resolves this project to.');
5129
+ findings.push({
5130
+ level: 'warn',
5131
+ title: 'The installed dflow package looks incomplete',
5132
+ detail: `Its packaged Git principles starter is unusable: ${causes.join('; ')}.${scope} This report therefore cannot say whether ${relativePath}'s canonical sections are current.`,
5133
+ action: `Reinstall dflow (e.g. \`npm install -g dflow-sdd-ddd@latest\`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project — ${alsoFails}`
5134
+ });
5135
+ }
5136
+
5137
+ // ⚠⚠ THE COMPARISON IS ROUTE (B) NOW, NOT `resolvedEdition` ONLY — and the
5138
+ // history of this line is worth keeping, because BOTH of its earlier forms
5139
+ // were wrong in opposite directions.
5140
+ //
5141
+ // It once compared against whichever single track the manifest named.
5142
+ // `p090-b3-y1` executed that gap: manifest forced to brownfield on a
5143
+ // structurally greenfield project, doctor compared the greenfield file
5144
+ // against the BROWNFIELD starter and reported drift that did not exist — a
5145
+ // resolver disagreement wearing a content finding. The repair narrowed it to
5146
+ // `resolvedEdition` (manifest and structure agreeing), which removed the
5147
+ // false DIRTY and installed a false CLEAN in its place: with the edition
5148
+ // uninferable the whole comparison vanished and said nothing. Measured on
5149
+ // the same mutation — an edited canonical region reported when the edition
5150
+ // was known, `All checks passed` when it was not.
5151
+ //
5152
+ // ⚠ And this proposal's own gate-1 work routed NEW traffic into that hole:
5153
+ // route (B) now enters this loop when no policy is recorded, while the (A)
5154
+ // fallback is suppressed exactly then (the starter file IS on disk, so
5155
+ // `policiesToCheck` is non-empty). Policy unrecorded + edition uninferable +
5156
+ // a drifted file produced neither the drift nor the "which checks did not
5157
+ // run" disclosure.
5158
+ //
5159
+ // Comparing every candidate and clearing on ANY match answers both: the
5160
+ // greenfield project matches the greenfield starter whatever the manifest
5161
+ // claims, so `p090-b3-y1`'s false dirty stays fixed, and an unknown edition
5162
+ // no longer decides whether we look. ⚠ The finding's own wording never named
5163
+ // a packaged file — "differs from the current packaged starter", with the
5164
+ // PROJECT's path in the title — so the old comment's justification for
5165
+ // narrowing ("that sentence names a specific packaged file") was defending
5166
+ // a sentence this code does not print.
5167
+ //
5168
+ // ⚠ Two editions really do ship different canonical regions for the same
5169
+ // policy (measured: trunk 10602 vs 10601 bytes, gitflow 11414 vs 11089), so
5170
+ // this is observable, not latent.
5171
+ const comparableEditions = damagedEditions.length === 0 ? candidateEditions : [];
5172
+
5173
+ if (!projectFileExists) {
5174
+ // ⚠ The recovery action may not send the reader to a scratch `dflow init`
5175
+ // while we KNOW that init would read the damaged starter — it would hand
5176
+ // them the same broken file and look like their own mistake. Same
5177
+ // discipline as `alsoFails`: say the confident thing only when it is true.
5178
+ findings.push({
5179
+ level: 'warn',
5180
+ title: `${relativePath} is missing`,
5181
+ detail: `The selected Git policy (\`${policy}\`) names this principles file; runtime branch gates and finish-feature guidance read it.`,
5182
+ // ⚠ The condition is "is every starter a scratch init could read
5183
+ // damaged", not "do we know the track". `p090-b3-y1` caught the
5184
+ // earlier `edition && !packagedUsable` form still sending the reader
5185
+ // to a scratch init when the edition was unknown and BOTH candidate
5186
+ // starters were broken — the one case where that advice is most
5187
+ // certainly wrong, because whichever track init picks it is damaged.
5188
+ action: damagedEditions.length > 0 && damagedEditions.length === candidateEditions.length
5189
+ ? 'Reinstall dflow first — the packaged copy of this starter is itself unusable (reported above), so a scratch `dflow init` would reproduce the damage. Then recover the file from a fresh `dflow init` in a scratch directory.'
5190
+ : 'Recover it from a fresh `dflow init` in a scratch directory.'
5191
+ });
5192
+ } else {
5193
+ // ⚠⚠ THE PROJECT-SIDE CHECKS DO NOT HANG OFF `edition`, AND TWO OF THE
5194
+ // THREE NEVER NEEDED IT. "Your markers are malformed" and "your file
5195
+ // predates the markers" are properties of the PROJECT'S OWN FILE — they
5196
+ // are decided by `classifyMarkedRegion` on bytes this function already
5197
+ // has, and no packaged template is consulted to reach either verdict.
5198
+ // Only the third branch, the canonical COMPARISON, needs the packaged
5199
+ // side, and it carries its own guard.
5200
+ //
5201
+ // ⚠ Gating all three on `edition` was the sixth boundary drawn around
5202
+ // this check and the last one still standing. `p090-b3-y2` executed it:
5203
+ // strip the manifest and every structural signal but keep
5204
+ // `_conventions.md` with its selected policy, and a pre-marker,
5205
+ // malformed, or stale project file produced
5206
+ // `All checks passed. No Dflow health findings detected.` — while
5207
+ // `configure-agents`, which resolves the track by asking, went on to warn
5208
+ // or refresh. An unknown edition is a fact about the PROJECT'S SHAPE; it
5209
+ // says nothing about whether the adopter's own file is broken, so it must
5210
+ // not decide whether we look.
5211
+ let projected = '';
5212
+ let unreadable = false;
5213
+ try {
5214
+ projected = await fs.readFile(absolute, 'utf8');
5215
+ } catch {
5216
+ unreadable = true;
5217
+ }
5218
+ // PROPOSAL-090 route B3: sections 1-5 are Dflow-canonical and
5219
+ // `configure-agents` refreshes them in place; everything else in this
5220
+ // file is the project's. Comparing the WHOLE file reported drift for
5221
+ // every project that had ever filled in its own CI / CD section, which
5222
+ // is the normal state — so the signal was noise and stayed unread.
5223
+ // Four states, reported apart on purpose.
5224
+ const region = classifyMarkedRegion(
5225
+ toLf(projected),
5226
+ GIT_PRINCIPLES_CANONICAL_START,
5227
+ GIT_PRINCIPLES_CANONICAL_END
5228
+ );
5229
+ if (region.state === 'malformed') {
5230
+ // ⚠ Never fold this into `absent`. A file whose markers were broken
5231
+ // by an edit would then read as "predates the markers", and the
5232
+ // adoption offer would replace sections 1-5 of a file nobody has
5233
+ // looked at. Say what is actually wrong.
5234
+ findings.push({
5235
+ level: 'warn',
5236
+ title: `${relativePath} has malformed git-principles-canonical markers`,
5237
+ detail: 'The START / END pair is incomplete or out of order, so `configure-agents` cannot refresh the canonical sections and will leave the file untouched.',
5238
+ action: `${restorePolicyFirst}Repair or remove the stray \`<!-- dflow-generated: git-principles-canonical ... -->\` markers, then re-run \`dflow configure-agents\`.`
5239
+ });
5240
+ } else if (region.state === 'absent') {
5241
+ // ⚠⚠ ONLY PROMISE THE OFFER WHEN THE OFFER WILL ACTUALLY BE MADE.
5242
+ // `configure-agents` offers marker adoption only for a file it can still
5243
+ // recognise — both heading anchors present exactly once
5244
+ // (`gitPrinciplesCanonicalBounds`). `p090-b3-z1` executed the gap: rename
5245
+ // `## 1. Branch Structure` and doctor still said "accept the
5246
+ // marker-adoption offer" while `configure-agents` never asked. That is a
5247
+ // false claim about another command — the class this file has already
5248
+ // corrected twice — and `docs/upgrading.*` documents the distinction the
5249
+ // code was not making.
5250
+ // ⚠ `unreadable` is split out for the same reason: a file we could not
5251
+ // read produced an empty string, which classifies as `absent`, and doctor
5252
+ // then reported "predates the markers" about content it never saw.
5253
+ const recognizable = gitPrinciplesCanonicalBounds(toLf(projected)) !== null;
5254
+ if (unreadable) {
5255
+ findings.push({
5256
+ level: 'warn',
5257
+ title: `${relativePath} could not be read`,
5258
+ detail: 'It exists but this check could not read it, so nothing can be said about whether its canonical sections are current.',
5259
+ action: 'Check the file\'s permissions and encoding, then re-run `dflow doctor`.'
5260
+ });
5261
+ } else if (recognizable) {
5262
+ findings.push({
5263
+ level: 'info',
5264
+ title: `${relativePath} predates managed git-principles-canonical markers`,
5265
+ detail: 'Its canonical sections (1-5) stay at the Dflow version that seeded them, while the workflow flows that read them are re-projected on every upgrade.',
5266
+ action: `${restorePolicyFirst}Re-run \`dflow configure-agents\` on an interactive terminal and accept the marker-adoption offer; it keeps your file header and everything from "## 6. AI Collaboration Rules (Project Policy)" down.`
5267
+ });
5268
+ } else {
5269
+ findings.push({
5270
+ level: 'info',
5271
+ title: `${relativePath} is not recognizable as a Dflow Git principles starter`,
5272
+ detail: `It has no git-principles-canonical markers, and its \`${GIT_PRINCIPLES_CANONICAL_FIRST_HEADING}\` / \`${GIT_PRINCIPLES_CANONICAL_AFTER_HEADING}\` headings were not both found exactly once, so Dflow cannot tell where its own sections end and yours begin.`,
5273
+ action: 'No marker-adoption offer is made for this file. Restore both headings if it should be Dflow-managed, or keep maintaining it yourself.'
5274
+ });
5275
+ }
5276
+ } else if (comparableEditions.length > 0) {
5277
+ const projectedCanonical = toLf(projected).slice(region.startIdx, region.endIdx);
5278
+ // Clearing on ANY candidate is the whole point: the project's file only
5279
+ // has to be a current starter for SOME shipped track to be current.
5280
+ const matchesSome = comparableEditions.some((candidate) => {
5281
+ const entry = packagedByEdition.get(candidate);
5282
+ return toLf(entry.template).slice(entry.region.startIdx, entry.region.endIdx) === projectedCanonical;
5283
+ });
5284
+ if (!matchesSome) {
5285
+ findings.push({
5286
+ level: 'info',
5287
+ title: `${relativePath} canonical sections differ from the current packaged starter`,
5288
+ detail: comparableEditions.length === 1
5289
+ ? 'Only sections 1-5 are compared; your own sections are not. This is either your edit inside the managed region, or a starter that has not been refreshed yet.'
5290
+ : 'Only sections 1-5 are compared; your own sections are not. This project does not say which track it uses, so every shipped track was checked and the sections match none of them. This is either your edit inside the managed region, or a starter that has not been refreshed yet.',
5291
+ action: `${restorePolicyFirst}Run \`dflow configure-agents\` to refresh the canonical sections in place; content outside the markers is kept.`
5292
+ });
5293
+ }
5294
+ }
5295
+ }
5296
+ }
5297
+
5298
+ // ⚠ (A), and only as the fallback route (B) cannot cover. With no policy
5299
+ // recorded AND no `Git-principles-*.md` on disk there is genuinely nothing to
5300
+ // compare, so this is the one shape where the missing value really does stop
5301
+ // the check — and doctor reports by exception, so staying silent here would
5302
+ // read as "those checks ran and found nothing".
5303
+ if (!recordedPolicy && policiesToCheck.length === 0) {
5304
+ // ⚠ "Add the section to the file" is the wrong instruction when the file is
5305
+ // not there — and this branch is reached most easily in exactly that case,
5306
+ // where the sibling finding below already tells the reader to recover the
5307
+ // whole file. Two findings in one report that disagree about what the reader
5308
+ // should do next is the confusion this proposal exists to remove, so ask
5309
+ // which state it is rather than wording for the commoner one.
5310
+ const conventionsMissing = !(await pathExists(path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md')));
5311
+ findings.push({
5312
+ level: 'info',
5313
+ title: 'The Git-principles starter checks did not run',
5314
+ detail: `${conventionsMissing
5315
+ ? 'There is no `dflow/specs/shared/_conventions.md` to read a Git policy from'
5316
+ : 'No Git policy is recorded in `dflow/specs/shared/_conventions.md`'}, and no \`dflow/specs/shared/Git-principles-*.md\` file was found to check on its own. Doctor cannot tell which principles file this project needs, so it checked neither its presence nor whether its canonical sections have drifted from the current packaged starter — and the runtime branch gates and finish-feature guidance read that file.`,
5317
+ action: conventionsMissing
5318
+ ? 'Recover `_conventions.md` first (see the finding about it below), keeping its `## Git Policy` section, then re-run `dflow configure-agents` and `dflow doctor`.'
5319
+ : 'Add the `## Git Policy` section to `_conventions.md` (canonical line: `Selected Git policy: `gitflow``, or `trunk`), then re-run `dflow configure-agents` and `dflow doctor`. While the policy is unrecorded, `configure-agents` also declines to refresh Git principles.'
5320
+ });
5321
+ }
5322
+
5323
+ // PROPOSAL-082 G5 / PROPOSAL-083 §4 — report a `_conventions.md` whose
5324
+ // sections predate the current contracts. User-owned: report only, never edit.
5325
+ //
5326
+ // `missing` and `stale` are reported at different levels on purpose.
5327
+ // `### SPEC-ID Format` and `### Slug Conventions` were re-parented by `59e0eb2`
5328
+ // on 2026-05-01 and no released version has ever projected them, so EVERY
5329
+ // existing project is `missing` for that one — it is content the developer was
5330
+ // never offered, not a health problem, and `info` says so without crying wolf.
5331
+ // `stale` means the section IS there and states a rule the shipped flows no
5332
+ // longer follow, which is a live contradiction inside their own conventions,
5333
+ // so it warns.
5334
+ // The file being GONE is the worst state of all, and it was the one state
5335
+ // doctor said nothing about: every `_conventions` check early-returns on a
5336
+ // missing path, and findConventionsDrift returns [] for empty input. Silence
5337
+ // on the worst case is the "reports success" failure this check exists to
5338
+ // avoid, so it is reported first and the drift scan is skipped (three
5339
+ // fingerprint findings about a file that does not exist is noise).
5340
+ // Present-but-empty is handled with absent, not with drift: a whitespace-only
5341
+ // file would otherwise emit one finding per fingerprint, which is noise about
5342
+ // a file that has no content at all. It must not fall through silently
5343
+ // either — that was the gap, and it made an emptier file look healthier than
5344
+ // a one-character one.
5345
+ const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
5346
+ const conventionsAbsent = !(await pathExists(conventionsPath));
5347
+ // ⚠ THREE STATES, NOT TWO. The read at the top of this function collapses to
5348
+ // '' on any error, so a file that is present with its contents intact — locked
5349
+ // by permissions, or replaced by a directory — was reported as "is empty",
5350
+ // and the action told the reader to recover it from a scratch init and retype
5351
+ // their answers. That is destructive advice about a file whose content is
5352
+ // still there. The `conventionsUnreadable` branch keeps the coupling this
5353
+ // block is load-bearing for (three sibling checks stay silent about an absent
5354
+ // or blank file ONLY because this one always reports it) — it still always
5355
+ // reports, it just stops guessing which of the three it is.
5356
+ const conventionsUnreadable = !conventionsAbsent && conventions === '' && await pathExists(conventionsPath)
5357
+ ? !(await fs.readFile(conventionsPath, 'utf8').then(() => true).catch(() => false))
5358
+ : false;
5359
+ if (conventionsAbsent || conventionsUnreadable || !conventions.trim()) {
5360
+ findings.push({
5361
+ level: 'warn',
5362
+ title: conventionsAbsent
5363
+ ? 'dflow/specs/shared/_conventions.md is missing'
5364
+ : (conventionsUnreadable
5365
+ ? 'dflow/specs/shared/_conventions.md could not be read'
5366
+ : 'dflow/specs/shared/_conventions.md is empty'),
5367
+ detail: conventionsUnreadable
5368
+ ? 'It exists but this check could not read it, so none of the conventions, Git policy or AI commit marker it records could be checked. Its contents may be perfectly intact.'
5369
+ : 'It records this project\'s spec-writing conventions, Git policy and AI commit marker, and Dflow reads it to answer those questions. Without it, doctor cannot check any of them.',
5370
+ action: conventionsUnreadable
5371
+ ? 'Check that it is a file and that its permissions allow reading, then re-run `dflow doctor`. Do not recreate it until you have confirmed the content is actually lost.'
5372
+ : 'Recover it from a fresh `dflow init` in a scratch directory and re-apply your project-specific answers; it is user-owned, so `dflow configure-agents` does not re-project it.'
5373
+ });
5374
+ } else {
5375
+ const conventionsDrift = doctorChecks.findConventionsDrift(conventions, edition);
5376
+ for (const drift of conventionsDrift) {
5377
+ const missing = drift.state === 'missing';
5378
+ // Say only what is known. A section can be absent because the project
5379
+ // predates it OR because the heading was renamed, and doctor cannot tell
5380
+ // which — an earlier version asserted the first, which is a false
5381
+ // statement to show someone who merely retitled a heading.
5382
+ const neverShipped = missing && drift.neverProjected
5383
+ ? ' No released version before this one projected this section, so a project created earlier will not have it.'
5384
+ : '';
5385
+ const detail = {
5386
+ missing: `Nothing under a heading matching "${drift.heading}" was found, so ${drift.rule} is not recorded. Either the section is absent or its heading was renamed.${neverShipped}`,
5387
+ stale: `The section is present but does not carry ${drift.rule}. ${drift.consequence}`,
5388
+ retired: `The section still carries ${drift.rule}, which PROPOSAL-082 retired. ${drift.consequence}`
5389
+ }[drift.state];
5390
+ // Titles must differ per finding: three retired fingerprints share the
5391
+ // "Ceremony Scaling (Project Application)" heading, so a heading-only
5392
+ // title printed the same line several times with only `detail` differing.
5393
+ const title = {
5394
+ missing: `dflow/specs/shared/_conventions.md has no § ${drift.heading} section`,
5395
+ stale: `dflow/specs/shared/_conventions.md § ${drift.heading} is missing ${drift.rule}`,
5396
+ retired: `dflow/specs/shared/_conventions.md § ${drift.heading} still carries ${drift.rule}`
5397
+ }[drift.state];
5398
+ findings.push({
5399
+ level: drift.level,
5400
+ title,
5401
+ detail,
5402
+ action: 'Compare against a fresh `dflow init` in a scratch directory and copy the section across; `_conventions.md` is yours and Dflow never rewrites it. See `docs/upgrading.md`.'
5403
+ });
5404
+ }
5405
+ }
5406
+
5407
+ // No _overview.md machine-format check: no packaged _overview template has
5408
+ // ever carried `| Tech stack |` / `| Migration / legacy context |` rows, so
5409
+ // their absence there is the canonical state, not drift. The machine-readable
5410
+ // home of those two context values is the guide's "## Project Context" table
5411
+ // — inference reads it and checkGuideProjectContextFormat reports drift
5412
+ // (PROPOSAL-076).
5413
+ }
5414
+
5415
+ // PROPOSAL-058 direction 2 (e): template-shape drift for filled feature
5416
+ // dashboards. Detection + pointers only — migrating a filled document needs
5417
+ // judgment (old content into new sections), so the migration itself is
5418
+ // AI-assisted, and completed/ features are deliberately not scanned (they keep
5419
+ // their historical shape — BUG-001 decision).
5420
+ // The project's bundle version from its manifest when it is a plain `x.y.z`;
5421
+ // null when there is no readable manifest or the value is anything else. Both
5422
+ // shape checks read it: a bundle newer than this CLI means the CLI's packaged
5423
+ // templates are not the current ones.
5424
+ // The bundle's version, read with the rule `checkBundleManifestVersion` uses:
5425
+ // `x.y.z`, or a prerelease `x.y.z-…`. Dflow ships no prerelease, but a
5426
+ // hand-edited or forked manifest can carry one — and read as "no version", a
5427
+ // newer bundle had its docs judged against this CLI's older templates.
5428
+ async function readBundleVersion(cwd) {
5429
+ const manifestResult = await readCurrentBundleManifest(cwd);
5430
+ return manifestResult.kind === 'ok' && typeof manifestResult.manifest.version === 'string'
5431
+ && /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(manifestResult.manifest.version) ? manifestResult.manifest.version : null;
5432
+ }
5433
+
5434
+ // How a bundle version compares with this CLI's: by its numeric core, so a
5435
+ // prerelease with a newer core is newer. (`compareVersions` would read the
5436
+ // patch of `0.15.1-rc.1` as 0.)
5437
+ function compareBundleVersion(bundleVersion) {
5438
+ return compareVersions(bundleVersion.replace(/-.*$/, ''), pkg.version);
5439
+ }
5440
+
5441
+ async function checkFeatureIndexShape(cwd, findings) {
5442
+ // ⚠⚠ PROPOSAL-091 gates 2 and 3. Both exits this function used to take were
5443
+ // the shape `checkInitOnlyStarters` has already had corrected four times, so
5444
+ // they are corrected here the same two ways it was.
5445
+ //
5446
+ // GATE 2 (`if (!edition) return`) — route (B), do not let the missing value
5447
+ // decide whether we look. An unknown edition is a fact about the PROJECT'S
5448
+ // SHAPE; it says nothing about whether the adopter's `_index.md` is stale. The
5449
+ // edition set is closed, so when the project cannot tell us, every track is a
5450
+ // candidate and a file is only reported when it matches NONE of them.
5451
+ //
5452
+ // GATE 3 (`catch { return }`) — route (A), and it must report. An unreadable
5453
+ // packaged template means the installed package is damaged, not that the
5454
+ // project is fine; `checkInitOnlyStarters` names this exact shape as "the
5455
+ // quietest state of all" and `checkWorkflowBundleSourceAndOrphans` records
5456
+ // that NOTHING may gate a package check. Same class, same remedy, same
5457
+ // wording as the sibling finding so one damaged install does not read as two
5458
+ // unrelated problems.
5459
+ const edition = await inferProjectBundleEdition(cwd);
5460
+ const candidateEditions = edition ? [edition] : [...BUNDLE_EDITIONS];
5461
+ const templates = [];
5462
+ const damaged = [];
5463
+ for (const candidate of candidateEditions) {
5464
+ try {
5465
+ templates.push({ edition: candidate, template: await readPackagedTemplate(candidate, 'templates/_index.md') });
5466
+ } catch (error) {
5467
+ damaged.push(`\`templates/${candidate}/templates/_index.md\` could not be read (${error && error.message ? error.message : error})`);
5468
+ }
5469
+ }
5470
+ if (damaged.length > 0) {
5471
+ const scope = edition
5472
+ ? ''
5473
+ : ' This project does not say which track it uses, so every shipped track was checked.';
5474
+ findings.push({
5475
+ level: 'warn',
5476
+ title: 'The installed dflow package looks incomplete',
5477
+ detail: `Its packaged feature dashboard template is unusable: ${damaged.join('; ')}.${scope} This report therefore cannot say whether the \`_index.md\` files under \`dflow/specs/features/active/\` still match the current template shape.`,
5478
+ action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project.'
5479
+ });
5480
+ }
5481
+ // ⚠⚠ SAME RULE AS `checkGuideCanonicalState`, AND IT IS HERE BECAUSE OF THE
5482
+ // SHAPE, NOT BECAUSE A ROUND CAUGHT IT HERE. A cross-family round reproduced
5483
+ // the false-dirty comparison on the guide only; this function carried the
5484
+ // identical `length === 0` guard, and fixing one of two identical siblings is
5485
+ // the exact failure this proposal documents four times over
5486
+ // ("the same defect was repaired four times while the one next door was left
5487
+ // untouched"). ⚠ It is currently unobservable HERE — both editions' packaged
5488
+ // `_index.md` happen to carry the same H2 set today, so a file matching one
5489
+ // matches the other — which makes it latent rather than absent, and latent is
5490
+ // why it has to be code and not a comment. The detail string is the part that
5491
+ // would lie first: it says "every shipped track was checked" while only the
5492
+ // readable one was.
5493
+ if (damaged.length > 0) return;
5494
+ // PROPOSAL-092 D4: with a bundle newer than this CLI, the packaged `_index.md`
5495
+ // is an older template, so comparing against it would call a current dashboard
5496
+ // older. `checkDocumentShapes` reports that state once, for both checks.
5497
+ const bundleVersion = await readBundleVersion(cwd);
5498
+ if (bundleVersion && compareBundleVersion(bundleVersion) > 0) return;
5499
+ // ⚠⚠ ENOENT AND EVERYTHING ELSE ARE DIFFERENT ANSWERS. "There is no active/
5500
+ // directory" means a project with no features in flight — nothing to report,
5501
+ // and that is the reason PROPOSAL-091's exclusion list judged this exit sound.
5502
+ // ⚠ But that reasoning only ever covered ENOENT: the same `catch` also
5503
+ // swallowed "active/ is there and cannot be listed" (a file in its place,
5504
+ // permissions), and doctor printed `All checks passed` over a feature set it
5505
+ // never looked at. The exclusion was made having read half the state space.
5506
+ const activeDir = path.join(cwd, 'dflow', 'specs', 'features', 'active');
5507
+ let entries = [];
5508
+ try {
5509
+ entries = await fs.readdir(activeDir, { withFileTypes: true });
5510
+ } catch (error) {
5511
+ if (error && error.code === 'ENOENT') return;
5512
+ findings.push({
5513
+ level: 'warn',
5514
+ title: 'dflow/specs/features/active/ could not be listed',
5515
+ detail: `It exists but this check could not read it (${error && (error.code || error.message)}), so no active feature dashboard was compared against the current template shape.`,
5516
+ action: 'Check that it is a directory and that its permissions allow reading, then re-run `dflow doctor`.'
5517
+ });
5518
+ return;
5519
+ }
5520
+ for (const entry of entries) {
5521
+ if (!entry.isDirectory()) continue;
5522
+ const relativePath = `dflow/specs/features/active/${entry.name}/_index.md`;
5523
+ // ⚠ Same split, per feature. A feature directory with no `_index.md` at all
5524
+ // stays silent (the approved shape); one whose `_index.md` is there and
5525
+ // unreadable is a check that disappeared, and says so.
5526
+ let content = null;
5527
+ let indexReadError = null;
5528
+ try {
5529
+ content = await fs.readFile(path.join(activeDir, entry.name, '_index.md'), 'utf8');
5530
+ } catch (error) {
5531
+ indexReadError = error;
5532
+ }
5533
+ if (content === null) {
5534
+ if (indexReadError && indexReadError.code !== 'ENOENT') {
5535
+ findings.push({
5536
+ level: 'warn',
5537
+ title: `${relativePath} could not be read`,
5538
+ detail: `It exists but this check could not read it (${indexReadError.code || indexReadError.message}), so it was not compared against the current template shape.`,
5539
+ action: 'Check that it is a file and that its permissions allow reading, then re-run `dflow doctor`.'
5540
+ });
5541
+ }
5542
+ continue;
5543
+ }
5544
+ // PROPOSAL-092 D4: a dashboard carrying a shape marker is judged by its number
5545
+ // in `checkDocumentShapes`. Comparing its sections here as well would report a
5546
+ // deliberate difference as an older shape — the one distinction the marker
5547
+ // exists to make. ANY marker-like line counts, readable or not: an unreadable
5548
+ // one is reported there as `uncertain`, and a section comparison here would put
5549
+ // a guess beside that honest answer.
5550
+ if (doctorChecks.findShapeMarkerLines(content).length > 0) continue;
5551
+ const perTrack = templates.map((candidate) => ({
5552
+ edition: candidate.edition,
5553
+ missing: doctorChecks.missingTemplateSections(candidate.template, content)
5554
+ }));
5555
+ // ⚠ "Missing from every candidate", not "missing from one of them". With the
5556
+ // track unknown this file could legitimately be either shape, so a section
5557
+ // present in one packaged template is not missing at all — reporting it
5558
+ // would assert a track this project never named.
5559
+ if (perTrack.some((track) => track.missing.length === 0)) continue;
5560
+ const detail = perTrack.length === 1
5561
+ ? `Missing section(s) vs the current template: ${perTrack[0].missing.join(', ')}. Ignore this if you removed them on purpose.`
5562
+ : `This project does not say which track it uses, so every shipped track was checked and this file matches none of them — ${perTrack.map((track) => `${track.edition}: ${track.missing.join(', ')}`).join('; ')}. Ignore this if you removed them on purpose.`;
5563
+ findings.push({
5564
+ level: 'info',
5565
+ title: `${relativePath} looks like an older _index.md template shape`,
5566
+ detail,
5567
+ action: 'Migrate with AI assistance: give your assistant this file plus the current template (`dflow/specs/shared/dflow-workflows/templates/_index.md` after re-projecting) and merge the existing content into the new shape. Completed features can stay as-is.'
5568
+ });
5569
+ }
5570
+ }
5571
+
5572
+ // PROPOSAL-078 phase 1: delivery-gap detection for the table-cell formatting
5573
+ // convention (P-072). Scans user-authored spec surfaces — domain/,
5574
+ // architecture/, migration/, features/active/ + backlog/, and loose root
5575
+ // docs — and skips shared/ (Dflow-managed bundle, guide, conventions
5576
+ // machinery) plus features/completed/ (archived shape stays — same boundary
5577
+ // as checkFeatureIndexShape). Emits ONE aggregated info finding; doctor
5578
+ // never edits user-authored specs (the `_conventions.md` version-line
5579
+ // advance stays configure-agents' only exception, per MAINTAINERS).
5580
+ async function checkSpecTableConventionComment(cwd, findings) {
5581
+ const specsRoot = path.join(cwd, 'dflow', 'specs');
5582
+ const hits = [];
5583
+ async function walk(dir, relParts) {
5584
+ let entries;
5585
+ try {
5586
+ entries = await fs.readdir(dir, { withFileTypes: true });
5587
+ } catch {
5588
+ return;
5589
+ }
5590
+ for (const entry of entries) {
5591
+ const rel = [...relParts, entry.name];
5592
+ if (entry.isDirectory()) {
5593
+ if (relParts.length === 0 && entry.name === 'shared') continue;
5594
+ if (relParts.length === 1 && relParts[0] === 'features' && entry.name === 'completed') continue;
5595
+ await walk(path.join(dir, entry.name), rel);
5596
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
5597
+ const content = await fs.readFile(path.join(dir, entry.name), 'utf8').catch(() => null);
5598
+ if (content === null) continue;
5599
+ if (doctorChecks.hasTableWithoutConventionComment(content)) {
5600
+ hits.push(`dflow/specs/${rel.join('/')}`);
5601
+ }
5602
+ }
5603
+ }
5604
+ }
5605
+ await walk(specsRoot, []);
5606
+ if (hits.length === 0) return;
5607
+ hits.sort();
5608
+ const shown = hits.slice(0, 5).join(', ');
5609
+ const more = hits.length > 5 ? ` (and ${hits.length - 5} more)` : '';
5610
+ findings.push({
5611
+ level: 'info',
5612
+ title: `${hits.length} spec doc(s) hold tables but lack the table-formatting convention comment`,
5613
+ detail: `${shown}${more}. Docs seeded before Dflow 0.13 (or written from scratch) miss the in-file reminder to keep table cells concise (\`<br>\` between short items, long narrative out of cells), so their tables tend to grow hard-to-read walls.`,
5614
+ // ⚠ "Only that one line", and PROPOSAL-092 is why: a template head also
5615
+ // carries the template's `<!-- dflow-shape: … -->` line, and copying the head
5616
+ // stamps the CURRENT number onto a doc of an older shape — a wrong number
5617
+ // doctor then trusts and stays silent about.
5618
+ action: 'With AI assistance, copy only the one-line `<!-- Formatting convention: keep table cells concise ... -->` comment from any current template (`dflow/specs/shared/dflow-workflows/templates/`) into each listed doc, near the top. Copy that line alone, not the template head around it: the head also carries the template\'s `<!-- dflow-shape: ... -->` line, and a doc gets its marker only through the procedure in docs/upgrading.en.md § Shape markers. Doctor never edits user-authored specs.'
5619
+ });
5620
+ }
5621
+
5622
+ // PROPOSAL-092: template shape markers. Every doc a flow creates from a template
5623
+ // inherits the template's `<!-- dflow-shape: {track}/{template} {n} -->` line, and
5624
+ // this compares that number with the CURRENT one — the number on the same line in
5625
+ // the template packaged with this CLI, not the project's bundle (the reference
5626
+ // `checkFeatureIndexShape` compares against too).
5627
+ //
5628
+ // Five outcomes, each at most ONE aggregated finding — one finding per file is the
5629
+ // noise PROPOSAL-078 already learned to fold (six OBTS `rules.md` files):
5630
+ // same number — silent: every difference is the adopter's own decision
5631
+ // older — info: which docs, which numbers, and what the registry says
5632
+ // changed in between, split into "add this" and "decide this"
5633
+ // newer — warn: the doc is newer than this CLI
5634
+ // no marker — info (D5): doctor does not judge these, and says so
5635
+ // unreadable — uncertain, under the PROPOSAL-084 contract
5636
+ // ⚠⚠ A doc without a marker is NEVER compared against a template (D7, user
5637
+ // 2026-09-25). Every such comparison reports placeholder-only sections, frontmatter
5638
+ // and replaceable sections as missing on real projects — the noise that teaches
5639
+ // people to skip doctor. The one-time procedure on the upgrade page is that
5640
+ // comparison, done once, with a person judging each difference.
5641
+ // ⚠ Docs on a zero-phase host that has not closed out are listed apart: every fix
5642
+ // these findings suggest is an edit, and a minimal host allows none outside its
5643
+ // closed closeout list; at closeout the docs move to `features/completed/`, which
5644
+ // is not scanned.
5645
+ const DOC_SHAPES_REGISTRY_PATH = path.join(PACKAGE_ROOT, 'lib', 'doc-shapes.json');
5646
+ const SHAPE_MARKERS_DOC_URL = 'https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/upgrading.en.md#shape-markers';
5647
+ // ⚠ Every path, never "(and N more)". These findings are the only record of which
5648
+ // docs doctor did not judge, and the one-time procedure works through the docs a
5649
+ // finding NAMES — a truncated list is an unjudged doc that nothing points at.
5650
+ function shapeDocList(entries, render = (e) => e.display) {
5651
+ return entries.map(render).join(', ');
5652
+ }
5653
+
5654
+ // PROPOSAL-092: the registry is packaged data doctor trusts to decide what is in
5655
+ // scope — an entry with no paths would make its docs vanish from every finding.
5656
+ // So its shape is checked before anything is read through it, and a damaged entry
5657
+ // is reported as a damaged package rather than skipped.
5658
+ function docShapeRegistryProblems(registry) {
5659
+ const problems = [];
5660
+ for (const [key, e] of Object.entries(registry.templates)) {
5661
+ const where = `\`lib/doc-shapes.json\` entry \`${key}\``;
5662
+ if (!/^(greenfield|brownfield)\/[A-Za-z0-9_.-]+\.md$/.test(key)) { problems.push(`${where} is not a {track}/{template} key`); continue; }
5663
+ if (!e || typeof e !== 'object') { problems.push(`${where} is not an object`); continue; }
5664
+ if (typeof e.source !== 'string' || !e.source) problems.push(`${where} has no source`);
5665
+ if (!Array.isArray(e.paths) || e.paths.length === 0 || !e.paths.every((p) => typeof p === 'string' && p)) problems.push(`${where} has no usable paths`);
5666
+ if (!Array.isArray(e.variable) || !Array.isArray(e.conditionalFields)) problems.push(`${where} has no variable / conditionalFields lists`);
5667
+ const shapes = Array.isArray(e.shapes) ? e.shapes : [];
5668
+ if (shapes.length === 0 || !shapes.every((s, i) => s && s.number === i + 1 && typeof s.digest === 'string' && Array.isArray(s.skeleton) && Array.isArray(s.changes))) {
5669
+ problems.push(`${where} has no usable shapes`);
5670
+ }
5671
+ }
5672
+ return problems;
5673
+ }
5674
+
5675
+ // A change in the notes-and-order half of a shape: `>` notes and HTML comments
5676
+ // (added, removed, moved or `reworded`) and `reordered` headings. None of them
5677
+ // changes a doc's structure — PROPOSAL-092 D4's third class.
5678
+ function isShapeTextChange(change) {
5679
+ return change.kind === 'reordered' || change.kind === 'reworded'
5680
+ || doctorChecks.isShapeTextItem(change.item) || doctorChecks.isShapeTextItem(change.from);
5681
+ }
5682
+
5683
+ function describeShapeChange(change) {
5684
+ const d = doctorChecks.describeShapeItem;
5685
+ if (change.kind === 'reordered') return `${doctorChecks.describeShapeOrder(change.parent)} changed`;
5686
+ if (change.kind === 'reworded') return `${d(change.to)} changed`;
5687
+ if (change.kind === 'added') return isShapeTextChange(change) ? `${d(change.item)} added` : d(change.item);
5688
+ if (change.kind === 'removed') return `${d(change.item)} removed`;
5689
+ if (change.kind === 'split') return `${d(change.from)} split into ${change.to.map(d).join(' + ')}`;
5690
+ return `${d(change.from)} ${change.kind} to ${d(change.to)}`;
5691
+ }
5692
+
5693
+ // Every doc under `dflow/specs/` a flow may have created from a template (D7):
5694
+ // `checkSpecTableConventionComment`'s boundary — `shared/` (Dflow-managed) and
5695
+ // frozen features skipped — plus `shared/_overview.md`, the one adopter-owned doc
5696
+ // under `shared/`, and with `features/` narrowed to `active/`: no flow writes a
5697
+ // template into `backlog/`, and `completed/` keeps its historical shape.
5698
+ // ⚠ A directory that cannot be listed is returned in `unreadable`, not skipped:
5699
+ // every doc under it goes unjudged, and saying nothing about it printed
5700
+ // `All checks passed` over docs doctor never opened. Only a missing
5701
+ // `dflow/specs/` itself is an empty scope.
5702
+ // ⚠ A symbolic link (a Windows junction too) is taken for what it points at, for
5703
+ // the same reason: skipping every entry that was not a plain file or directory
5704
+ // passed a linked `glossary.md` without opening it. A link that cannot be
5705
+ // followed is named as unread. A link back to a directory the walk is already
5706
+ // inside ends there instead of looping; any other alias is walked under its own
5707
+ // path, because its docs may sit at a flow path the other path does not.
5708
+ async function listShapeScopeDocuments(specsRoot) {
5709
+ const docs = [];
5710
+ const unreadable = [];
5711
+ const failure = (error) => (error && (error.code || error.message)) || 'unknown error';
5712
+ async function walk(dir, relParts, ancestors) {
5713
+ let entries;
5714
+ let real;
5715
+ try {
5716
+ real = await fs.realpath(dir);
5717
+ if (ancestors.has(real)) return;
5718
+ entries = await fs.readdir(dir, { withFileTypes: true });
5719
+ } catch (error) {
5720
+ if (!(relParts.length === 0 && error && error.code === 'ENOENT')) {
5721
+ unreadable.push({ display: `dflow/specs/${relParts.join('/')}${relParts.length ? '/' : ''}`, code: failure(error) });
5722
+ }
5723
+ return;
5724
+ }
5725
+ const inside = new Set(ancestors).add(real);
5726
+ for (const entry of entries) {
5727
+ const rel = [...relParts, entry.name];
5728
+ // `features/` holds feature directories, and only `active/` is in scope:
5729
+ // a file directly under `features/` belongs to no feature, so it is out too.
5730
+ if (relParts.length === 1 && relParts[0] === 'features' && entry.name !== 'active') continue;
5731
+ let isDirectory = entry.isDirectory();
5732
+ let isFile = entry.isFile();
5733
+ if (entry.isSymbolicLink()) {
5734
+ try {
5735
+ const target = await fs.stat(path.join(dir, entry.name));
5736
+ isDirectory = target.isDirectory();
5737
+ isFile = target.isFile();
5738
+ } catch (error) {
5739
+ unreadable.push({ display: `dflow/specs/${rel.join('/')}`, code: failure(error) });
5740
+ continue;
5741
+ }
5742
+ }
5743
+ if (isDirectory) {
5744
+ if (relParts.length === 0 && entry.name === 'shared') continue;
5745
+ await walk(path.join(dir, entry.name), rel, inside);
5746
+ } else if (isFile && entry.name.endsWith('.md')) {
5747
+ docs.push(rel.join('/'));
5748
+ }
5749
+ }
5750
+ }
5751
+ await walk(specsRoot, [], new Set());
5752
+ // Absent is fine; a link that points nowhere is a doc that cannot be read.
5753
+ const overview = path.join(specsRoot, 'shared', '_overview.md');
5754
+ let present = false;
5755
+ try {
5756
+ const entry = await fs.lstat(overview);
5757
+ present = true;
5758
+ if (entry.isSymbolicLink()) await fs.stat(overview);
5759
+ docs.push('shared/_overview.md');
5760
+ } catch (error) {
5761
+ if (present || !(error && error.code === 'ENOENT')) {
5762
+ unreadable.push({ display: 'dflow/specs/shared/_overview.md', code: failure(error) });
5763
+ }
5764
+ }
5765
+ return { docs: docs.sort(), unreadable };
5766
+ }
5767
+
5768
+ async function checkDocumentShapes(cwd, findings) {
5769
+ const packageDamage = [];
5770
+ let registry = null;
5771
+ try {
5772
+ registry = JSON.parse(await fs.readFile(DOC_SHAPES_REGISTRY_PATH, 'utf8'));
5773
+ if (!registry || typeof registry.templates !== 'object' || registry.templates === null) {
5774
+ throw new Error('it has no "templates" object');
5775
+ }
5776
+ } catch (error) {
5777
+ registry = null;
5778
+ packageDamage.push(`\`lib/doc-shapes.json\` could not be read (${error && error.message ? error.message : error})`);
5779
+ }
5780
+ if (registry) {
5781
+ const problems = docShapeRegistryProblems(registry);
5782
+ // Coverage in the other direction, by the covered set's own rule (D3) —
5783
+ // every spec template under `templates/<track>/templates/`, and
5784
+ // `scaffolding/_overview.md` — which is the rule `test/doc-shapes.mjs` holds
5785
+ // the source tree to. Not by the markers: a template that lost its marker
5786
+ // and its entry together was found by neither, and its unmarked docs dropped
5787
+ // out of every finding. Any other packaged file that carries a marker needs
5788
+ // an entry as well.
5789
+ for (const track of BUNDLE_EDITIONS) {
5790
+ for (const sub of ['templates', 'scaffolding']) {
5791
+ let names;
5792
+ try {
5793
+ names = (await fs.readdir(path.join(TEMPLATE_ROOT, track, sub))).filter((n) => n.endsWith('.md'));
5794
+ } catch (error) {
5795
+ problems.push(`\`templates/${track}/${sub}/\` could not be listed (${(error && (error.code || error.message)) || 'unknown error'})`);
5796
+ continue;
5797
+ }
5798
+ if (sub === 'scaffolding' && !names.includes('_overview.md')) {
5799
+ problems.push(`\`templates/${track}/scaffolding/_overview.md\` is missing`);
5800
+ }
5801
+ for (const name of names) {
5802
+ const covered = sub === 'templates' || name === '_overview.md';
5803
+ if (!covered) {
5804
+ const text = await fs.readFile(path.join(TEMPLATE_ROOT, track, sub, name), 'utf8').catch(() => '');
5805
+ if (doctorChecks.findShapeMarkerLines(text).length === 0) continue;
5806
+ }
5807
+ const entry = registry.templates[`${track}/${name}`];
5808
+ if (!entry || entry.source !== `${sub}/${name}`) {
5809
+ problems.push(covered
5810
+ ? `\`templates/${track}/${sub}/${name}\` is a template the shape check covers, but \`lib/doc-shapes.json\` has no entry for it`
5811
+ : `\`templates/${track}/${sub}/${name}\` carries a shape marker but \`lib/doc-shapes.json\` has no entry for it`);
5812
+ }
5813
+ }
5814
+ }
5815
+ }
5816
+ if (problems.length > 0) {
5817
+ packageDamage.push(...problems);
5818
+ registry = null;
5819
+ }
5820
+ }
5821
+
5822
+ // The current number of every template: its own marker, in THIS package.
5823
+ const current = new Map();
5824
+ for (const [key, entry] of Object.entries(registry ? registry.templates : {})) {
5825
+ const track = key.split('/')[0];
5826
+ let text;
5827
+ try {
5828
+ text = await readPackagedTemplate(track, entry.source);
5829
+ } catch (error) {
5830
+ packageDamage.push(`\`templates/${track}/${entry.source}\` could not be read (${error && error.message ? error.message : error})`);
5831
+ continue;
5832
+ }
5833
+ const marker = doctorChecks.readShapeMarker(text);
5834
+ if (marker.state !== 'ok' || `${marker.track}/${marker.template}` !== key) {
5835
+ packageDamage.push(`\`templates/${track}/${entry.source}\` has no readable \`${key}\` shape marker`);
5836
+ continue;
5837
+ }
5838
+ // The template's own number is the registry's last shape. Judging against
5839
+ // any other pair would invent a transition the registry cannot describe.
5840
+ if (marker.number !== entry.shapes.length) {
5841
+ packageDamage.push(`\`templates/${track}/${entry.source}\` carries shape ${marker.number}, but \`lib/doc-shapes.json\` lists ${entry.shapes.length} shape(s) for \`${key}\``);
5842
+ continue;
5843
+ }
5844
+ current.set(key, marker.number);
5845
+ }
5846
+ // Kept so the docs it leaves unjudged can be named once the scan has run.
5847
+ let packageFinding = null;
5848
+ if (packageDamage.length > 0) {
5849
+ packageFinding = {
5850
+ level: 'warn',
5851
+ title: 'The installed dflow package looks incomplete',
5852
+ detail: `Its template shape data is unusable: ${packageDamage.join('; ')}. This report therefore cannot say whether spec docs written from ${registry ? 'those templates' : 'any template'} are behind the current template shape.`,
5853
+ action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project.'
5854
+ };
5855
+ findings.push(packageFinding);
5856
+ if (!registry) return;
5857
+ }
5858
+
5859
+ // D4: a bundle newer than this CLI means its templates are the current ones, and
5860
+ // this CLI's are not — judging against them would call a current doc newer.
5861
+ const bundleVersion = await readBundleVersion(cwd);
5862
+ if (bundleVersion && compareBundleVersion(bundleVersion) > 0) {
5863
+ findings.push({
5864
+ level: 'warn',
5865
+ title: `This project's workflow bundle comes from Dflow ${bundleVersion}, newer than this CLI (${pkg.version})`,
5866
+ detail: 'The template shape check compares each spec doc\'s `<!-- dflow-shape: ... -->` number with the templates packaged in this CLI, which are older than the ones this project was projected from, so it would treat an older template as the current one. Both template shape checks were skipped: the shape markers, and the section comparison of feature \`_index.md\` files without one. (Usually another branch or another machine ran a newer Dflow.)',
5867
+ action: 'Upgrade the CLI (e.g. `npm install -g dflow-sdd-ddd@latest`), then re-run `dflow doctor`.'
5868
+ });
5869
+ return;
5870
+ }
5871
+ const bundleOlder = Boolean(bundleVersion) && compareBundleVersion(bundleVersion) < 0;
5872
+
5873
+ const specsRoot = path.join(cwd, 'dflow', 'specs');
5874
+ const { docs, unreadable: unlistable } = await listShapeScopeDocuments(specsRoot);
5875
+ const contents = new Map();
5876
+ const unread = [...unlistable];
5877
+ for (const rel of docs) {
5878
+ try {
5879
+ contents.set(rel, await fs.readFile(path.join(specsRoot, rel), 'utf8'));
5880
+ } catch (error) {
5881
+ unread.push({ display: `dflow/specs/${rel}`, code: (error && (error.code || error.message)) || 'unknown error' });
5882
+ }
5883
+ }
5884
+ if (unread.length > 0) {
5885
+ findings.push({
5886
+ level: 'warn',
5887
+ title: `${unread.length} path(s) under dflow/specs/ could not be read, so the template shape of what they hold was not judged`,
5888
+ detail: `${shapeDocList(unread, (e) => `${e.display} (${e.code})`)}. Doctor could not open these, so it cannot say whether any spec doc in them is behind the current template shape — or carries a shape marker at all.`,
5889
+ action: 'Check that each is readable and of the right kind (a file where a file belongs, a directory where a directory belongs), then re-run `dflow doctor`.'
5890
+ });
5891
+ }
5892
+ // Zero-phase hosts still open: `_index.md` with an empty `## Phase Specs` table
5893
+ // AND no phase spec in the directory. An empty table beside a phase spec is a
5894
+ // host doctor cannot place (PROPOSAL-092 D5): listed apart, with both ways out.
5895
+ // A phase spec is whatever the registry says a phase spec's path is.
5896
+ const phaseSpecPaths = Object.entries(registry.templates)
5897
+ .filter(([key]) => key.endsWith('/phase-spec.md'))
5898
+ .flatMap(([, entry]) => entry.paths);
5899
+ const hostsWithPhaseSpec = new Set(docs
5900
+ .filter((rel) => phaseSpecPaths.some((p) => doctorChecks.shapePathMatches(p, rel)))
5901
+ .map((rel) => rel.split('/')[2]));
5902
+ const openMinimalHosts = new Set();
5903
+ const unclearHosts = new Set();
5904
+ for (const [rel, content] of contents) {
5905
+ const m = rel.match(/^features\/active\/([^/]+)\/_index\.md$/);
5906
+ if (!m || doctorChecks.sectionTableRowCount(content, 'Phase Specs') !== 0) continue;
5907
+ (hostsWithPhaseSpec.has(m[1]) ? unclearHosts : openMinimalHosts).add(m[1]);
5908
+ }
5909
+ const edition = await inferProjectBundleEdition(cwd);
5910
+ const templateKeys = Object.keys(registry.templates).filter((key) => !edition || key.startsWith(`${edition}/`));
5911
+
5912
+ const older = [];
5913
+ const newer = [];
5914
+ const unmarked = [];
5915
+ const unreadable = [];
5916
+ // Marked docs whose template this package cannot give a current number for:
5917
+ // never judged, so named in the package finding instead of skipped.
5918
+ const damaged = [];
5919
+ for (const [rel, content] of contents) {
5920
+ const host = rel.match(/^features\/active\/([^/]+)\//);
5921
+ const entry = {
5922
+ display: `dflow/specs/${rel}`,
5923
+ onOpenHost: Boolean(host) && openMinimalHosts.has(host[1]),
5924
+ onUnclearHost: Boolean(host) && unclearHosts.has(host[1])
5925
+ };
5926
+ const marker = doctorChecks.readShapeMarker(content);
5927
+ if (marker.state === 'absent') {
5928
+ const mapped = templateKeys.some((key) => registry.templates[key].paths.some((p) => doctorChecks.shapePathMatches(p, rel)));
5929
+ if (mapped) unmarked.push(entry);
5930
+ continue;
5931
+ }
5932
+ if (marker.state === 'unreadable') {
5933
+ if (marker.reason === 'multiple') entry.why = `marker lines at ${marker.lines.join(', ')}`;
5934
+ else if (marker.reason === 'misplaced') {
5935
+ entry.why = `line ${marker.lines[0]} mentions \`dflow-shape:\`, but a marker is read only from line ${marker.standard}`;
5936
+ // The one cause doctor can name: a doc copied whole from the projected
5937
+ // copy, whose first line is the bundle's own. Frontmatter under it is not
5938
+ // frontmatter to `dflow render` either.
5939
+ const firstLine = content.split(/\r?\n/)[0].replace(/^\uFEFF/, '').trim();
5940
+ if (firstLine === WORKFLOW_BUNDLE_GENERATED_MARKER) {
5941
+ entry.why += `; line 1 is \`${WORKFLOW_BUNDLE_GENERATED_MARKER}\`, copied from the template's projected copy — delete it and the blank line after it`;
5942
+ }
5943
+ // Frontmatter behind a byte-order mark is not frontmatter to render
5944
+ // either, so the marker under it is not on its line.
5945
+ if (marker.bomHidesFrontmatter) {
5946
+ entry.why += '; the file starts with a byte-order mark (BOM), which hides its frontmatter from `dflow render` too — save it as UTF-8 without a BOM';
5947
+ }
5948
+ }
5949
+ else entry.why = `line ${marker.lines[0]} is not a well-formed marker`;
5950
+ unreadable.push(entry);
5951
+ continue;
5952
+ }
5953
+ const key = `${marker.track}/${marker.template}`;
5954
+ if (!registry.templates[key]) {
5955
+ entry.why = `line ${marker.line} names \`${key}\`, which this CLI does not ship`;
5956
+ unreadable.push(entry);
5957
+ continue;
5958
+ }
5959
+ const now = current.get(key);
5960
+ if (now === undefined) { damaged.push(entry); continue; }
5961
+ if (marker.number === now) continue;
5962
+ if (marker.number > now) newer.push({ ...entry, key, number: marker.number, now });
5963
+ else older.push({ ...entry, key, from: marker.number, to: now });
5964
+ }
5965
+
5966
+ if (packageFinding && damaged.length > 0) {
5967
+ packageFinding.detail += ` Not judged because of it: ${shapeDocList(damaged)}.`;
5968
+ }
5969
+
5970
+ const apart = (e) => e.onOpenHost || e.onUnclearHost;
5971
+ // A doc on an open minimal host must not be touched before closeout (D5), so
5972
+ // an action that says "edit these docs" says first which ones it leaves out —
5973
+ // the detail naming them "leave alone" while the action said "add markers"
5974
+ // gave the adopter two opposite instructions.
5975
+ const hostAction = (entries) => {
5976
+ const parts = [];
5977
+ if (entries.some((e) => e.onOpenHost)) {
5978
+ parts.push('Leave the docs listed as not yet closed out alone: at closeout they drop out of this check.');
5979
+ }
5980
+ if (entries.some((e) => e.onUnclearHost)) {
5981
+ parts.push('For the docs whose feature doctor cannot place, first decide whether the feature is a minimal host; if it is, leave them alone too.');
5982
+ }
5983
+ return parts.length === 0 ? '' : `${parts.join(' ')} For every other doc: `;
5984
+ };
5985
+ const hostNote = (entries) => {
5986
+ const render = (e) => e.why ? `${e.display} (${e.why})` : e.display;
5987
+ const onHost = entries.filter((e) => e.onOpenHost);
5988
+ const unclear = entries.filter((e) => e.onUnclearHost);
5989
+ return [
5990
+ onHost.length === 0 ? '' : ` Not yet closed out, so leave these alone for now — a minimal host allows no change outside its closeout list, and at closeout they move to features/completed/ and drop out of this check: ${shapeDocList(onHost, render)}.`,
5991
+ unclear.length === 0 ? '' : ` Doctor cannot tell whether these belong to a minimal host that is still open — the feature's \`_index.md\` lists no phase under \`## Phase Specs\`, yet its directory holds a phase spec: ${shapeDocList(unclear, render)}. If the feature is a minimal host, leave them alone until closeout, like an open host; if it is not, handle them like the rest${entries === older ? ' (they are listed with the rest above)' : ''}.`
5992
+ ].join('');
5993
+ };
5994
+
5995
+ if (older.length > 0) {
5996
+ const groups = new Map();
5997
+ // A doc on a host doctor cannot place stays in its group: if the feature is
5998
+ // not a minimal host, what changed is exactly what the adopter needs. The
5999
+ // host note names it again, with the other way out.
6000
+ for (const e of older.filter((x) => !x.onOpenHost)) {
6001
+ const g = `${e.key} ${e.from} → ${e.to}`;
6002
+ if (!groups.has(g)) groups.set(g, { ...e, docs: [] });
6003
+ groups.get(g).docs.push(e);
6004
+ }
6005
+ const described = [...groups.entries()].map(([label, g]) => {
6006
+ const changes = registry.templates[g.key].shapes
6007
+ .filter((s) => s.number > g.from && s.number <= g.to)
6008
+ .flatMap((s) => s.changes || []);
6009
+ const structural = changes.filter((c) => !isShapeTextChange(c));
6010
+ const add = structural.filter((c) => c.kind === 'added').map(describeShapeChange);
6011
+ const decide = structural.filter((c) => c.kind !== 'added').map(describeShapeChange);
6012
+ const text = changes.filter(isShapeTextChange).map(describeShapeChange);
6013
+ const parts = [
6014
+ add.length ? `added — ${add.join('; ')}` : '',
6015
+ decide.length ? `renamed, split, moved or removed — ${decide.join('; ')}` : '',
6016
+ text.length ? `notes, comments and section order, which do not change the doc's structure — ${text.join('; ')}` : ''
6017
+ ].filter(Boolean).join('. ');
6018
+ return `\`${label}\` (${shapeDocList(g.docs)}): ${parts || 'the registry lists no change'}.`;
6019
+ });
6020
+ const overview = older.some((e) => e.key.endsWith('/_overview.md'))
6021
+ ? ' `_overview.md` is not projected into dflow-workflows/: compare it with `templates/<track>/scaffolding/_overview.md` in the installed package, or a fresh `dflow init` in a scratch directory.'
6022
+ : '';
6023
+ findings.push({
6024
+ level: 'info',
6025
+ title: `${older.length} spec doc(s) were written against an older template shape`,
6026
+ detail: `${described.length > 0 ? described.join(' ') : 'None outside the minimal hosts listed next.'}${hostNote(older)}`,
6027
+ action: `${hostAction(older)}${bundleOlder ? 'Run `dflow configure-agents` first, so the templates under dflow-workflows/ are the current ones. ' : ''}For each ADDED item, add it to the doc — the section, the column or the frontmatter field; give existing rows \`{TBD}\` and leave a comment above the table saying what the value is, when to backfill it and how to tell it is done. Add the shape, not the content. Renamed, split, moved and removed items are reported only: decide how to adapt the doc yourself, because filling them in blindly leaves the old and the new section side by side. Notes, comments and section order do not change the doc's structure: compare them with the current template and decide, with your AI assistant, whether to bring the doc in line — keeping yours is a valid answer. When a doc is handled — added to, brought in line, or a difference kept on purpose — change the number on its \`<!-- dflow-shape: ... -->\` line to the current one.${overview} Doctor never edits user-authored specs.`
6028
+ });
6029
+ }
6030
+
6031
+ if (newer.length > 0) {
6032
+ findings.push({
6033
+ level: 'warn',
6034
+ title: `${newer.length} spec doc(s) carry a shape number newer than this CLI knows`,
6035
+ detail: `${shapeDocList(newer, (e) => `${e.display} (\`${e.key}\` ${e.number}; this CLI: ${e.now})`)}. Usually another branch or another machine used a newer Dflow; this CLI cannot say what those numbers mean.`,
6036
+ action: 'Upgrade the CLI (e.g. `npm install -g dflow-sdd-ddd@latest`), then re-run `dflow doctor`.'
6037
+ });
6038
+ }
6039
+
6040
+ if (unmarked.length > 0) {
6041
+ const open = unmarked.filter((e) => !apart(e));
6042
+ const listed = open.length > 0 ? `${shapeDocList(open)}.` : 'None outside the minimal hosts listed below.';
6043
+ const editionNote = edition
6044
+ ? ''
6045
+ : ' This project does not say which track it uses (greenfield or brownfield), and a marker names one: confirm the track before adding markers.';
6046
+ findings.push({
6047
+ level: 'info',
6048
+ title: `${unmarked.length} spec doc(s) have no shape marker, so doctor does not judge their template shape`,
6049
+ detail: `${listed} Without the \`<!-- dflow-shape: ... -->\` line a doc takes from its template, doctor cannot tell a template that changed from a change you made on purpose, so it says nothing about these docs' shape rather than guess. Only docs at the paths Dflow's flows create them at are counted; a renamed or moved doc is not.${editionNote}${hostNote(unmarked)}`,
6050
+ action: `${hostAction(unmarked)}Add the markers once, with AI assistance: compare each doc with the current template, keep what you changed on purpose, add what the template added later (shape, not content), decide yourself how to adapt what it renamed, split, moved or removed, then give the doc the template's marker line with the current number. The procedure: ${SHAPE_MARKERS_DOC_URL} (offline copy: docs/upgrading.en.md in the installed package).`
6051
+ });
6052
+ }
6053
+
6054
+ if (unreadable.length > 0) {
6055
+ const open = unreadable.filter((e) => !apart(e));
6056
+ findings.push(uncertainFinding({
6057
+ id: SHAPE_MARKER_UNREADABLE_ID,
6058
+ title: `${unreadable.length} spec doc(s) have a shape marker doctor cannot read`,
6059
+ detail: `${open.length > 0 ? `${shapeDocList(open, (e) => `${e.display} (${e.why})`)}. ` : ''}A doc needs exactly one line mentioning \`dflow-shape:\` — its marker, on the first line (or, after frontmatter, on the line right after the closing \`---\`), of the form \`<!-- dflow-shape: {track}/{template} {number} -->\` (anything after the number is optional) naming a template this CLI ships. Doctor does not judge whether a marker-like line anywhere else — in a list, a quote, a comment or a code block — is live, and does not guess what a damaged line meant.${hostNote(unreadable)}`,
6060
+ affects: ['the template shape check for these docs — whether each one is behind the current template shape'],
6061
+ action: `${hostAction(unreadable)}Leave exactly one marker line in each doc, at that position. A marker quoted as an example, or an old one commented out, counts too: reword it so it no longer contains \`dflow-shape:\`. If you know which template and number the doc had, write the line back with that number (the line in the installed template carries the current number, which would mark an older doc as current); if you are not sure, treat the doc as having no marker and follow ${SHAPE_MARKERS_DOC_URL} (offline copy: docs/upgrading.en.md in the installed package).`
6062
+ }));
6063
+ }
6064
+ }
6065
+
6066
+ // PROPOSAL-058: root agent shim manageability. Only existing files are
6067
+ // classified — which agents a project uses is not recorded, so an absent file is
6068
+ // not a finding; a file with no Dflow content at all is the user's own business.
6069
+ async function checkRootAgentShims(cwd, findings) {
6070
+ for (const agent of ['agents', 'claude', 'copilot']) {
6071
+ const target = getAiAgentTarget(agent);
6072
+ const raw = await fs.readFile(path.join(cwd, target.relativePath), 'utf8').catch(() => null);
6073
+ if (raw === null) continue;
6074
+ const lf = toLf(raw);
6075
+ // AGENTS.md can carry a second managed pair (the Codex command-trigger
6076
+ // block); a malformed trigger pair blocks `--command-adapters` trigger
6077
+ // management (snippet fallback) even when the agent-shim block is healthy.
6078
+ if (agent === 'agents') {
6079
+ const triggerRegion = classifyMarkedRegion(lf, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
6080
+ if (triggerRegion.state === 'malformed') {
6081
+ findings.push({
6082
+ level: 'warn',
6083
+ title: `${target.relativePath} has malformed Dflow command-trigger markers`,
6084
+ detail: '`dflow configure-agents --command-adapters` cannot manage the trigger block and falls back to a merge snippet.',
6085
+ action: 'Remove the stray `<!-- dflow-generated: codex-command-triggers ... -->` markers, then re-run `dflow configure-agents --command-adapters`.'
6086
+ });
6087
+ }
6088
+ }
6089
+ const region = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
6090
+ if (region.state === 'malformed') {
6091
+ findings.push({
6092
+ level: 'warn',
6093
+ title: `${target.relativePath} has malformed Dflow markers`,
6094
+ detail: '`dflow configure-agents` cannot manage the Dflow block and falls back to a merge snippet.',
6095
+ action: 'Remove the stray Dflow markers, then re-run `dflow configure-agents`.'
6096
+ });
6097
+ continue;
6098
+ }
6099
+ if (region.state === 'present') continue; // marker-managed: refreshed in place
6100
+ const baseShim = buildAiAgentShim(target.relativePath);
6101
+ if (isPristineDflowAgentsShim(raw, baseShim, target.relativePath)) continue; // regenerated in place
6102
+ if (contentReferencesAiAgentGuide(raw)) {
6103
+ findings.push({
6104
+ level: 'info',
6105
+ title: `${target.relativePath} references the Dflow guide but is not Dflow-managed`,
6106
+ detail: 'Dflow wording inside it stays frozen on upgrade (no markers, and not a pristine Dflow shim).',
6107
+ action: 'Re-run `dflow configure-agents` on an interactive terminal and accept the managed-block offer, then remove any older Dflow wording you keep outside the block.'
6108
+ });
6109
+ }
6110
+ }
6111
+ }
6112
+
6113
+ // PROPOSAL-091 problem 1, route R3 (user decision, 2026-08-29). Until this
6114
+ // check shipped, NOTHING in doctor read a single path under `.claude/`,
6115
+ // `.agents/` or `.github/prompts/`: an adopter could delete the entire command
6116
+ // adapter and skill layer and doctor's output did not change by one byte.
6117
+ //
6118
+ // ⚠⚠ THE NAME IS PART OF THE SPEC. Three of the seven rows below judge SKILL
6119
+ // files, and the skill row is the only one in this whole proposal with a
6120
+ // measured instance behind it (OBTS on `develop`: three `SKILL.md` files still
6121
+ // carrying the `0.14.0` description while `doctor` said `All checks passed`).
6122
+ // Calling this `checkCommandAdapters` is how the skill half gets dropped.
6123
+ //
6124
+ // ⚠⚠ IT ONLY JUDGES FILES THAT ARE ALREADY THERE. "One adapter and no skill at
6125
+ // all" is SILENT, deliberately, and that silence is a residual risk this
6126
+ // proposal accepted rather than an oversight — it is disclosed to adopters in
6127
+ // `docs/doctor-uncertainty.md` § "Shapes that are known and deliberately not
6128
+ // reported". Three attempts to detect absence were each specified and each
6129
+ // found to misfire on a legitimate population: `PROPOSAL-058` records that
6130
+ // "which agents a project uses is not recorded, so an absent file is not a
6131
+ // finding", and `PROPOSAL-037` (user-approved, 2026-05-22) actively RECOMMENDS
6132
+ // that adopters gitignore the adapters and regenerate them after a clone. Both
6133
+ // of the states doctor would have to separate — "I never wanted them" and "I
6134
+ // had them and they went" — look identical on disk. The superseding fix is a
6135
+ // product decision, not a better heuristic: `command-adapters-install-by-default`
6136
+ // in `planning/opt-in-backlog.md`. If that lands, revisit this comment and the
6137
+ // disclosure page together.
6138
+ //
6139
+ // ⚠ PER-AGENT, NOT PER-PROJECT, AND THE THREE AGENTS ARE NOT THE SAME SHAPE:
6140
+ // claude — `.claude/commands/dflow/<id>.md`, its own directory.
6141
+ // copilot — `.github/prompts/dflow-<id>.prompt.md`, a SHARED namespace, so
6142
+ // the `dflow-` glob is the unit and the directory is not.
6143
+ // codex — has NO command adapter at all (`addCommandAdapterItems` has only
6144
+ // the claude and copilot branches; `PROPOSAL-037` § 6 is the
6145
+ // user-approved decision not to generate them, so that absence is
6146
+ // canonical). ⚠⚠ But codex DOES have a skill: `SKILL_ADAPTER_TARGETS`
6147
+ // keys it `agents`, at `.agents/skills/dflow/SKILL.md`. Writing
6148
+ // "codex is silent" wholesale deletes a third of the coverage of the
6149
+ // one row that has a measurement behind it.
6150
+ async function checkAdapterAndSkillState(cwd, findings) {
6151
+ // --- Rows 1 and 6: command adapters, for the two agents that have them ---
6152
+ const commandSurfaces = [
6153
+ {
6154
+ label: '`.claude/commands/dflow/`',
6155
+ relativePathFor: (id) => `.claude/commands/dflow/${id}.md`,
6156
+ flag: '--command-adapters'
6157
+ },
6158
+ {
6159
+ label: '`.github/prompts/dflow-*.prompt.md`',
6160
+ relativePathFor: (id) => `.github/prompts/dflow-${id}.prompt.md`,
6161
+ flag: '--command-adapters'
6162
+ }
6163
+ ];
6164
+ for (const surface of commandSurfaces) {
6165
+ const present = [];
6166
+ const missing = [];
6167
+ const notFiles = [];
6168
+ for (const id of EXPECTED_COMMAND_IDS) {
6169
+ // ⚠⚠ `pathExists` IS TOO WEAK FOR THIS ROW, and the gap was a silent pass
6170
+ // of exactly the kind this check exists to close: it answers "is there
6171
+ // something at this path", while the row is about command adapter FILES.
6172
+ // A directory named `status.md` counted as installed, doctor printed
6173
+ // `All checks passed`, and `configure-agents --command-adapters` then died
6174
+ // EISDIR partway through rewriting the surface. Three states, not two.
6175
+ // (`addLegacyCommandAdapterCleanupItems` already stats and refuses to act
6176
+ // on a non-file for the same reason — this row just had not caught up.)
6177
+ const relativePath = surface.relativePathFor(id);
6178
+ // eslint-disable-next-line no-await-in-loop
6179
+ const stats = await fs.stat(path.join(cwd, relativePath)).catch(() => null);
6180
+ if (stats === null) missing.push(id);
6181
+ else if (stats.isFile()) present.push(id);
6182
+ else notFiles.push(relativePath);
6183
+ }
6184
+ if (notFiles.length > 0) {
6185
+ const shown = notFiles.slice(0, 3).join(', ');
6186
+ const more = notFiles.length > 3 ? ` (and ${notFiles.length - 3} more)` : '';
6187
+ findings.push({
6188
+ level: 'warn',
6189
+ title: `${notFiles.length} Dflow command adapter path(s) are not files`,
6190
+ detail: `${shown}${more}. Something other than a command adapter file occupies these paths, so this surface cannot be used or regenerated as it stands.`,
6191
+ action: `Remove or rename them, then run \`dflow configure-agents ${surface.flag}\`. Left in place, that command fails partway through rewriting this surface.`
6192
+ });
6193
+ }
6194
+ // Row 5: not one of them is there. Silent — see the header comment.
6195
+ if (present.length === 0) continue;
6196
+ if (missing.length === 0) continue;
6197
+ findings.push({
6198
+ level: 'warn',
6199
+ title: `${surface.label} has ${present.length} of ${EXPECTED_COMMAND_IDS.length} Dflow command adapters`,
6200
+ // ⚠ Say both readings. Doctor cannot separate "I deleted the ones I do not
6201
+ // use" from "the last generation was interrupted" — the files look the
6202
+ // same either way — and this is the noisier level of the two, so the
6203
+ // wording has to carry the ambiguity the level does not.
6204
+ detail: `Missing: ${missing.join(', ')}. Either some were removed on purpose, or a generation run did not finish; the files on disk cannot tell doctor which.`,
6205
+ action: `Run \`dflow configure-agents ${surface.flag}\` to regenerate the full set, or ignore this if you removed them deliberately.`
6206
+ });
6207
+ }
6208
+
6209
+ // --- Row 2: 0.5.0-era filenames left behind ---
6210
+ // Derived from LEGACY_COMMAND_ADAPTERS rather than restated, so a future entry
6211
+ // is picked up here without a second list to keep in sync. Reported on the
6212
+ // file's own evidence: unlike the cleanup path in `configure-agents`, doctor
6213
+ // has no selected-agent list to gate on, and does not need one — the file
6214
+ // being there IS the finding.
6215
+ for (const legacy of LEGACY_COMMAND_ADAPTERS) {
6216
+ const stale = [];
6217
+ for (const command of legacy.commands) {
6218
+ const relativePath = legacy.pathPattern.replace('<id>', command.id);
6219
+ // ⚠ Files only, same reason as the row above, and here it also keeps the
6220
+ // finding's own wording true: it calls these "command adapters using the
6221
+ // 0.5.0 filename", which is a claim about a file. A directory of that name
6222
+ // is not one, and `configure-agents` would not remove it either — it stats
6223
+ // and declines on a non-file.
6224
+ // eslint-disable-next-line no-await-in-loop
6225
+ const stats = await fs.stat(path.join(cwd, relativePath)).catch(() => null);
6226
+ if (stats && stats.isFile()) stale.push(relativePath);
6227
+ }
6228
+ if (stale.length === 0) continue;
6229
+ const shown = stale.slice(0, 3).join(', ');
6230
+ const more = stale.length > 3 ? ` (and ${stale.length - 3} more)` : '';
6231
+ findings.push({
6232
+ level: 'warn',
6233
+ title: `${stale.length} command adapter(s) still use the Dflow ${legacy.version} filename`,
6234
+ detail: `${shown}${more}. Dflow ${legacy.version} wrote \`${legacy.pathPattern}\`; the current layout is \`${legacy.pathPattern.replace(`dflow-<id>`, '<id>')}\`, so these are duplicates that shadow nothing and can drift from the command registry.`,
6235
+ action: `Run \`dflow configure-agents --command-adapters\`, which removes the ones it recognises as Dflow-generated and names any it will not touch.`
6236
+ });
6237
+ }
6238
+
6239
+ // --- Rows 3, 4 and 7: skill files, all three agents alike ---
6240
+ // ⚠ The packaged skill is read ONLY once a marker-bearing project skill has
6241
+ // been found, so a project with no skill at all cannot be told its install is
6242
+ // broken — and a project that HAS one is never told nothing at all. The
6243
+ // `catch { return }` this avoids is the shape `checkInitOnlyStarters` records
6244
+ // as "the quietest state of all".
6245
+ let packagedSkill = null;
6246
+ let packagedSkillError = null;
6247
+ for (const [agent, target] of Object.entries(SKILL_ADAPTER_TARGETS)) {
6248
+ // ⚠⚠ ABSENT AND UNREADABLE ARE DIFFERENT STATES, and one `catch(() => null)`
6249
+ // collapsed them. Row 5's silence is about a file that is NOT THERE; a file
6250
+ // that IS there and cannot be read is the INV-1 shape — something to check
6251
+ // exists, and the check disappeared. `checkInitOnlyStarters` splits exactly
6252
+ // these two apart, for exactly this reason, 400 lines up in this same file;
6253
+ // this loop was the one place that did not. Narrow in practice (invalid
6254
+ // UTF-8 does not reach here — `readFile(…,'utf8')` substitutes U+FFFD — so
6255
+ // it takes a permission or type error), which is an argument about how often
6256
+ // it fires, not about whether the silence is wrong.
6257
+ // ⚠ THIS SPLIT IS NOT APPLIED EVERYWHERE. Four checks this proposal did not
6258
+ // touch — `checkGuideSectionRefs`, `checkSpecTableConventionComment`,
6259
+ // `checkRootAgentShims`, `checkWorkflowBundleSourceAndOrphans` — still fold
6260
+ // an unreadable path into the absent branch and report nothing at all
6261
+ // (measured: each prints `All checks passed`). Declined by the maintainer
6262
+ // 2026-08-30 as a user-system permission/lock condition rather than
6263
+ // something Dflow claims to guard, and recorded as
6264
+ // `doctor-unreadable-vs-absent-sweep` in `planning/opt-in-backlog.md`.
6265
+ // ⚠ If you ever come to make those four consistent, this is the shape to
6266
+ // copy — and that row is where the reasoning for leaving them lives.
6267
+ const skillPath = path.join(cwd, target.relativePath);
6268
+ let existing = null;
6269
+ let unreadable = false;
6270
+ try {
6271
+ existing = await fs.readFile(skillPath, 'utf8');
6272
+ } catch (error) {
6273
+ if (error && error.code !== 'ENOENT') {
6274
+ unreadable = true;
6275
+ findings.push({
6276
+ level: 'warn',
6277
+ title: `${target.relativePath} could not be read`,
6278
+ detail: `It exists but this check could not read it (${error.code || error.message}), so nothing can be said about whether it is the skill this Dflow version projects.`,
6279
+ action: 'Check the path\'s permissions and that it is a file rather than a directory, then re-run `dflow doctor`.'
6280
+ });
6281
+ }
6282
+ }
6283
+ if (unreadable) continue;
6284
+ // Row 5 again: no skill file for this agent. Silent.
6285
+ if (existing === null) continue;
6286
+ // Row 4: no Dflow marker means the overwrite guard in `addSkillAdapterItems`
6287
+ // already treats it as the user's own file and leaves it alone. Doctor has
6288
+ // no business reporting drift against a template it is not the source of.
6289
+ if (!existing.includes(SKILL_ADAPTER_GENERATED_MARKER)) continue;
6290
+ if (packagedSkill === null && packagedSkillError === null) {
6291
+ // ⚠⚠ "READABLE" IS NOT "USABLE", AND THIS ONE HAD TEETH. The read-error
6292
+ // branch alone is only half the rule `checkGuideCanonicalState` follows:
6293
+ // there, a packaged template that is readable but structurally unusable
6294
+ // (no marker region) is ALSO reported as package damage. Here a packaged
6295
+ // skill of zero bytes, or one with the marker stripped, sailed past — and
6296
+ // then every comparison below failed, so doctor told a project whose three
6297
+ // `SKILL.md` files this very CLI had just projected that they "no longer
6298
+ // match this version". That is a false statement about the reader's files
6299
+ // (INV-2), with the blame pointed the wrong way: the sibling check would
6300
+ // have said "a problem with the installed package, not with anything in
6301
+ // your project".
6302
+ //
6303
+ // ⚠⚠ And the action it printed was destructive, which is what sets the
6304
+ // severity. Measured end to end: with the packaged skill emptied, doctor
6305
+ // said "Run `dflow configure-agents --skills` to regenerate it", and doing
6306
+ // exactly that overwrote all three project files 1925 -> 0 bytes and
6307
+ // exited 0. Doctor's advice destroyed the files it had just misdescribed.
6308
+ // ⚠⚠ DECODE STRICTLY, the way `readPackagedTemplate` decodes every OTHER
6309
+ // packaged file. `buildDflowSkillAdapter` is a plain
6310
+ // `readFile(..., 'utf8')`, which never fails: invalid bytes become U+FFFD
6311
+ // and the string sails through "non-empty" and "has the marker". Measured:
6312
+ // four invalid bytes appended to the packaged skill made doctor tell a
6313
+ // project whose three `SKILL.md` files it had just projected that they
6314
+ // "no longer match this version", and following the printed action
6315
+ // rewrote all three with replacement characters (1925 -> 1935 bytes,
6316
+ // exit 0). Emptiness and a missing marker were only two of the ways this
6317
+ // file can be unusable; corruption was the third and the loudest.
6318
+ let candidate = null;
6319
+ try {
6320
+ const buffer = await fs.readFile(path.join(TEMPLATE_ROOT, COMMON_SKILL_SOURCE_REL));
6321
+ candidate = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
6322
+ } catch (error) {
6323
+ packagedSkillError = error;
6324
+ }
6325
+ // A projected skill is defined by its marker — the same token row 4 uses to
6326
+ // decide a file is Dflow's rather than the user's — so a source without it
6327
+ // cannot be the thing we compare against.
6328
+ const usable = candidate !== null
6329
+ && candidate.trim().length > 0
6330
+ && candidate.includes(SKILL_ADAPTER_GENERATED_MARKER);
6331
+ if (usable) {
6332
+ packagedSkill = candidate;
6333
+ } else {
6334
+ if (packagedSkillError === null) packagedSkillError = new Error('unusable');
6335
+ const cause = candidate === null
6336
+ ? `could not be read as UTF-8 text (${packagedSkillError && packagedSkillError.message ? packagedSkillError.message : packagedSkillError})`
6337
+ : (candidate.trim().length === 0
6338
+ ? 'is empty'
6339
+ : 'carries no `dflow-generated: skill-adapter` marker');
6340
+ findings.push({
6341
+ level: 'warn',
6342
+ title: 'The installed dflow package looks incomplete',
6343
+ // ⚠ The action below is the ONLY defence against this. The write side
6344
+ // still projects an unusable packaged skill verbatim and exits 0 —
6345
+ // declined by the maintainer 2026-08-30 (it takes an already-damaged
6346
+ // install) and recorded as `configure-agents-writes-empty-skill` in
6347
+ // `planning/opt-in-backlog.md`. So this wording is load-bearing:
6348
+ // whoever reads it is the last thing standing between a broken
6349
+ // package and three overwritten `SKILL.md` files.
6350
+ detail: `Its packaged skill source ${cause}. This report therefore cannot say whether the Dflow-generated \`SKILL.md\` files in this project are current — and \`dflow configure-agents --skills\` would project this same unusable content over them.`,
6351
+ action: 'Reinstall dflow (e.g. `npm install -g dflow-sdd-ddd@latest`, or re-link your local checkout), and do NOT run `configure-agents --skills` until you have. This is a problem with the installed package, not with anything in your project.'
6352
+ });
6353
+ }
6354
+ }
6355
+ if (packagedSkill === null) continue;
6356
+ // Row 3 (and row 7, which is this row applied to codex). The `description`
6357
+ // frontmatter in this file is the text a tool matches to decide whether to
6358
+ // engage at all, so a stale copy is not a cosmetic lag — it is the natural
6359
+ // language entry point still using the previous release's boundary.
6360
+ if (doctorChecks.matchesTemplateWithPlaceholders(existing, packagedSkill)) continue;
6361
+ findings.push({
6362
+ level: 'info',
6363
+ title: `${target.relativePath} differs from the skill this CLI projects`,
6364
+ detail: 'It carries the Dflow skill marker, so Dflow generated it, and its content no longer matches this version. Its `description` frontmatter is what the tool matches on to auto-engage, so a stale copy keeps an older trigger boundary.',
6365
+ action: `Run \`dflow configure-agents --skills\` to regenerate it (\`--skills\` is required: a flagless run does not refresh a skill that already exists). ${agent === 'agents' ? 'This is the Codex skill path.' : ''}`.trim()
6366
+ });
6367
+ }
6368
+ }
6369
+
6370
+ // PROPOSAL-058 direction 2 (c) companion: the bundle manifest records which
6371
+ // Dflow version last projected the workflow bundle.
6372
+ async function checkBundleManifestVersion(cwd, findings) {
6373
+ const manifestResult = await readCurrentBundleManifest(cwd);
6374
+ // ⚠⚠ PROPOSAL-091 gate 7 — disclosure, and the two non-ok kinds are NOT one
6375
+ // state. `readCurrentBundleManifest` already separates them and
6376
+ // `configure-agents` already acts on that split (it reports the file and
6377
+ // degrades on corrupt), so a single `kind !== 'ok'` return here made doctor
6378
+ // the only one of the three that could not tell them apart. ABSENT is the
6379
+ // legal state of a project whose bundle has never been projected — nothing to
6380
+ // report. CORRUPT is damage, and it silently switches off both this check and
6381
+ // the manifest half of `inferProjectBundleEdition`; saying nothing about it is
6382
+ // the silent pass this proposal exists to close.
6383
+ // ⚠ A manifest that IS readable but whose `version` field is absent or
6384
+ // malformed still returns silently below. Deliberate, not an oversight:
6385
+ // `buildBundleManifest` has always written the field, so reaching that state
6386
+ // takes a hand edit of a Dflow-managed file. Declined by the maintainer
6387
+ // 2026-08-30 and recorded as `p091-manifest-version-field-absent` in
6388
+ // `planning/opt-in-backlog.md`, with what would reopen it.
6389
+ if (manifestResult.kind === 'absent') return;
6390
+ if (manifestResult.kind === 'corrupt') {
6391
+ findings.push({
6392
+ level: 'warn',
6393
+ title: `${WORKFLOW_BUNDLE_MANIFEST_PATH} could not be read`,
6394
+ detail: `${manifestResult.reason}. This check therefore cannot say which Dflow version projected the workflow bundle, and the edition the manifest records is unavailable to every check that asks for it. \`dflow configure-agents\` reports the same file and degrades: it skips stale cleanup and leaves the manifest untouched.`,
6395
+ action: `Delete ${WORKFLOW_BUNDLE_MANIFEST_PATH} and run \`dflow configure-agents\` to rebuild it from the current bundle.`
6396
+ });
6397
+ return;
6398
+ }
6399
+ const version = manifestResult.manifest.version;
6400
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(version) || version === pkg.version) return;
6401
+ if (compareVersions(version, pkg.version) < 0) {
6402
+ findings.push({
6403
+ level: 'info',
6404
+ title: `Workflow bundle was projected by Dflow ${version}; this CLI is ${pkg.version}`,
6405
+ detail: 'The references/ and templates/ files under dflow-workflows/ are from the older version.',
6406
+ action: 'Run `dflow configure-agents` to re-project the bundle.'
6407
+ });
6408
+ }
6409
+ }
6410
+
3237
6411
  // PROPOSAL-052 (c): read-only mop-up for the manifest-orphan edge. A
3238
6412
  // Dflow-generated bundle file that is no longer in the current package source
3239
6413
  // can linger if it was retired before generalized stale-removal shipped, or the
@@ -3243,19 +6417,185 @@ async function checkConventionsDflowVersion(cwd, findings) {
3243
6417
  // deleted by hand. Doctor detects and reports such files read-only (never
3244
6418
  // deletes). Detection requires a directory scan because, by definition, the
3245
6419
  // manifest no longer lists the orphan — but a read-only scan is safe here.
3246
- async function checkOrphanedWorkflowBundleFiles(cwd, findings) {
3247
- const bundleDir = path.join(cwd, WORKFLOW_BUNDLE_DEST);
3248
- if (!(await pathExists(bundleDir))) return;
3249
-
6420
+ async function checkWorkflowBundleSourceAndOrphans(cwd, findings) {
6421
+ // ⚠ NOTHING GATES THE PACKAGE CHECK, and EVERY shipped edition is checked. Four
6422
+ // repairs guessed at a boundary here before it was accepted that there isn't one —
6423
+ // see the comment on the `for (const e of dependedOn)` loop below for what each
6424
+ // guess let through. The last guess was the subtlest: checking only the project's
6425
+ // OWN edition, which is exactly enough to answer "will this block me" and not
6426
+ // enough to answer "is my install damaged". Those are different questions and the
6427
+ // report now answers both.
3250
6428
  const edition = await inferProjectBundleEdition(cwd);
3251
- if (!edition) return;
6429
+ // Needed before the catch, not after: the "your retired-file scan was lost" clause
6430
+ // may only be said where that scan was actually going to run (`projgate-y1` F5).
6431
+ const bundleDir = path.join(cwd, WORKFLOW_BUNDLE_DEST);
6432
+ const bundleExists = await pathExists(bundleDir);
6433
+
6434
+ // ⚠⚠ TWO RESOLVERS, AND THEY DO NOT AGREE. `inferProjectBundleEdition` prefers the
6435
+ // manifest (authoritative for what was PROJECTED, which is what the orphan scan
6436
+ // below compares against). `configure-agents` resolves by STRUCTURE alone. On a
6437
+ // project where they disagree — manifest says brownfield, the tree looks greenfield
6438
+ // — doctor would name a track as unused while `configure-agents` was about to read
6439
+ // exactly that track and hard-fail on it. `projgate-sol1` executed it: doctor said
6440
+ // "does not use", configure exited 1 on the same damage. That is the original
6441
+ // false-clean rebuilt through a different mechanism, and it is a claim this file
6442
+ // only started making an hour earlier.
6443
+ // So the project is treated as depending on ONE edition only when BOTH resolvers
6444
+ // name the same one. Anything else — disagreement, or either resolver silent — and
6445
+ // every edition counts as depended-upon. Conservative, and the cost is only that a
6446
+ // damaged unused track reads as `warn` instead of `info` on an already-inconsistent
6447
+ // project.
6448
+ const structureEdition = await inferExistingEdition(cwd);
6449
+ const resolvedEdition = edition && edition === structureEdition ? edition : null;
6450
+ const dependedOn = resolvedEdition ? [resolvedEdition] : [...BUNDLE_EDITIONS];
6451
+ const unused = BUNDLE_EDITIONS.filter((e) => !dependedOn.includes(e));
3252
6452
 
3253
6453
  let sourceFiles;
3254
6454
  try {
3255
- sourceFiles = await listBundleSourceFiles(edition);
3256
- } catch {
6455
+ // ⚠⚠ THIS LOOP IS THE PACKAGE INTEGRITY CHECK, and the comment below is the only
6456
+ // record of why it has the shape it has. It used to live on a named function that a
6457
+ // later repair left unreachable; `projgate-y2r` caught that the pointer above landed
6458
+ // on dead code a routine sweep would delete. Keep it attached to code that RUNS.
6459
+ //
6460
+ // ⚠⚠ THE HISTORY MATTERS, because three separate repairs each moved this boundary
6461
+ // instead of removing it, and each one was reported as a fix. `projgate-x1`: the
6462
+ // check sat behind "is a bundle projected". `projgate-x2`: behind "is an edition
6463
+ // inferable". `projgate-y1`: a common-tree-only scan, which reached exactly ONE of
6464
+ // the three integrity asserts — `assertEditionBundleComplete` never ran, and
6465
+ // `assertNoBundleCollision` over one tree is close to a no-op, since `references/x`
6466
+ // and `templates/x` cannot key-collide and one readdir cannot return two same-case
6467
+ // names. So a cross-tree collision or a gutted edition tree still produced
6468
+ // `All checks passed` beside a `configure-agents` that hard-failed on the same
6469
+ // package. There is nothing to infer: the edition set is CLOSED and both trees
6470
+ // always ship, so when the project cannot tell us, check them all.
6471
+ //
6472
+ // ⚠⚠ ONE PER EDITION, NEVER BOTH IN ONE LIST — and this is the part that looks like
6473
+ // a style choice and is not. `assertNoBundleCollision` keys on `sourceRel` and
6474
+ // deliberately NOT on `sourceRoot`, because its job is to catch one destination path
6475
+ // claimed twice. Merging greenfield and brownfield into a single list therefore
6476
+ // reports every same-named per-track file as a collision, which on the real tree is
6477
+ // every flow reference there is. Measured on the healthy tree: the merged form
6478
+ // throws on `references/drift-verification.md` — a FALSE POSITIVE shipped to every
6479
+ // adopter, strictly worse than the defect it was meant to fix. `projgate-y1` found
6480
+ // the gap correctly and prescribed exactly that merge; the prescription was checked
6481
+ // before it was applied, which is the only reason it is not in this file.
6482
+ for (const e of dependedOn) {
6483
+ const files = await listBundleSourceFiles(e);
6484
+ if (e === edition) sourceFiles = files;
6485
+ }
6486
+ } catch (error) {
6487
+ // ⚠⚠ This catch used to be bare (`catch { return; }`) and it was a false-clean
6488
+ // generator: the three source-integrity asserts inside listBundleSourceFiles
6489
+ // (collision, complete edition tree, complete common tree) fire on a broken
6490
+ // INSTALL, and swallowing them skipped the orphan scan in silence — so `doctor`
6491
+ // printed `All checks passed` and exited 0 on a package whose `configure-agents`
6492
+ // hard-fails before writing a byte. Reproduced end-to-end by `feedbackcommon-xv6`
6493
+ // (2026-08-10): delete one required common file and the two commands disagree
6494
+ // completely. Pre-existing, not introduced by the common-tree single-sourcing.
6495
+ //
6496
+ // ⚠⚠ THE FIRST VERSION OF THIS FIX SAT BELOW THE BUNDLE-DIRECTORY CHECK AND
6497
+ // LEFT HALF THE DEFECT IN PLACE (`projgate-x1`, 2026-08-11). The reasoning then
6498
+ // was: `All checks passed` is a claim about the PROJECT, so a directory with no
6499
+ // projected bundle has abandoned no claim and deserves no warning. That
6500
+ // conflated two different states — a bare directory that is not a Dflow project
6501
+ // at all, and an INITIALIZED project whose bundle is missing. The second is a
6502
+ // Dflow project, doctor does make a claim about it, and the reviewer reproduced
6503
+ // exactly that: delete the project's bundle directory, break the package, and
6504
+ // the old order printed `All checks passed` again. The repair then moved the
6505
+ // gate to "can an edition be inferred" — and `projgate-x2` reached THAT state
6506
+ // too (strip the manifest plus every structural signal). Two wrong guesses at
6507
+ // the same boundary is the signal this repo records as a design question rather
6508
+ // than a third patch — and the third guess (a common-tree-only scan) was wrong
6509
+ // too, for a reason only the other model family saw — see the comment on the
6510
+ // `for (const e of dependedOn)` loop above. The packaged source is now validated
6511
+ // unconditionally, every edition of it.
6512
+ //
6513
+ // ⚠ Level is `warn`, not PROPOSAL-084 `uncertain`. Nothing here is uncertain: a
6514
+ // named assert fired and the remedy is unambiguous. `warn` already suppresses
6515
+ // `All checks passed` (see printDoctorReport), so `uncertain` would buy no extra
6516
+ // honesty while putting a broken install onto an explainer page written for
6517
+ // unreadable PROJECT shapes — and would drag in the id + affects + bilingual
6518
+ // page contract for a state the reader cannot act on differently.
6519
+ //
6520
+ // ⚠ Nothing is rethrown. An unexpected read error must not turn a read-only
6521
+ // health check into a non-zero exit, but it must not vanish either — that is
6522
+ // the same silent-skip class, just with a different trigger.
6523
+ // ⚠ The "and therefore the scan did not run" clause may only be said where that
6524
+ // scan was actually going to run: it needs BOTH an inferred edition and a
6525
+ // projected bundle to compare against. A doctor message that reports a loss which
6526
+ // did not occur is the same class of false operational claim this whole fix
6527
+ // exists to remove — one notch smaller, which is why the first version got the
6528
+ // edition half right and the bundle half wrong (`projgate-y1` F5).
6529
+ const scanLost = edition && bundleExists
6530
+ ? ' The retired-bundle-file scan did not run, so this report cannot say whether your project holds retired bundle files.'
6531
+ : '';
6532
+ if (error instanceof InitError) {
6533
+ // ⚠ "configure-agents will fail on this package too" is only TRUE when we know
6534
+ // which edition THAT command will resolve to. Two rounds narrowed this: with no
6535
+ // edition at all the damage may sit in a tree the project never reads
6536
+ // (`projgate-x3`), and with the two resolvers disagreeing we do not know which
6537
+ // tree it will read (`projgate-sol1`). Both collapse into the same test — say
6538
+ // it flatly only when the resolvers agree. Say what is known, not what sounds
6539
+ // decisive.
6540
+ const alsoFails = resolvedEdition
6541
+ ? '`dflow configure-agents` will fail on this package too.'
6542
+ : 'Whether `dflow configure-agents` also fails depends on which track it resolves this project to.';
6543
+ findings.push({
6544
+ level: 'warn',
6545
+ title: 'The installed dflow package looks incomplete',
6546
+ detail: `An integrity check on its packaged workflow bundle source failed: ${error.message}${scanLost}`,
6547
+ action: `Reinstall dflow (e.g. \`npm install -g dflow-sdd-ddd@latest\`, or re-link your local checkout). This is a problem with the installed package, not with anything in your project — ${alsoFails}`
6548
+ });
6549
+ } else {
6550
+ findings.push({
6551
+ level: 'warn',
6552
+ title: 'Could not read the packaged workflow bundle source',
6553
+ detail: `Reading it failed: ${error && error.message ? error.message : error}.${scanLost}`,
6554
+ action: 'Check that the installed dflow package is readable (permissions, an interrupted install), then re-run `dflow doctor`.'
6555
+ });
6556
+ }
3257
6557
  return;
3258
6558
  }
6559
+
6560
+ // ⚠ DISCLOSE, DO NOT BLOCK (user decision, 2026-08-11). A track this project does
6561
+ // not use can be damaged without anything here ever failing — `projgate-x3`
6562
+ // measured it: a greenfield project on a package with a gutted brownfield tree has
6563
+ // `doctor` and `configure-agents` BOTH succeeding, and both are right, because that
6564
+ // project never reads that tree. So this is not the false-clean the `warn` above
6565
+ // exists for; the two commands agree. What was wrong was only that the report
6566
+ // implied a completeness it had not checked.
6567
+ // The honest answer is a different question with a different level: "will this
6568
+ // block you" is `warn`, "is your install damaged" is `info`. Raising this to `warn`
6569
+ // would re-create the original disagreement in the opposite direction — doctor
6570
+ // saying broken while `configure-agents` runs fine — which trades one false claim
6571
+ // for another rather than removing it.
6572
+ // ⚠ Reached only when the loop above did NOT throw: if the track you depend on is
6573
+ // broken, "reinstall" already covers everything and a second finding is noise.
6574
+ for (const other of unused) {
6575
+ try {
6576
+ await listBundleSourceFiles(other);
6577
+ } catch (error) {
6578
+ findings.push({
6579
+ level: 'info',
6580
+ title: `The installed dflow package is damaged in the ${other} track, which this project does not use`,
6581
+ detail: `${error && error.message ? error.message : error}`,
6582
+ // ⚠ `resolvedEdition`, not `edition`: this sentence is only reachable when both
6583
+ // resolvers agreed, and naming the manifest's answer here when they had not
6584
+ // would be the same false claim one line further down.
6585
+ // ⚠ The install is shared. A global `dflow` serves every project on this
6586
+ // machine, so "does not block THIS project" is not "harmless" — said without
6587
+ // the second sentence it invites a reader to leave a damaged install in place
6588
+ // for a sibling project that does use that track (`projgate-sol1` ITEM 2.1).
6589
+ action: `Nothing here blocks this project — it uses the ${resolvedEdition} track, and neither \`dflow init\` nor \`dflow configure-agents\` reads the ${other} source tree for it. But this install is shared: any other project on this machine that uses the ${other} track will hit it. Reinstall dflow to clear it — and do so before you switch this project to the ${other} track.`
6590
+ });
6591
+ }
6592
+ }
6593
+
6594
+ // Everything below is the ORPHAN SCAN, and it is the only part that needs both an
6595
+ // edition (to know the full source set) and a projected bundle (to have something
6596
+ // to compare against). Package integrity was settled above, independent of both.
6597
+ if (!edition || !bundleExists) return;
6598
+
3259
6599
  const sourceRel = new Set(sourceFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
3260
6600
 
3261
6601
  for (const dir of ['references', 'templates']) {
@@ -3297,27 +6637,136 @@ async function inferProjectBundleEdition(cwd) {
3297
6637
  return inferExistingEdition(cwd);
3298
6638
  }
3299
6639
 
6640
+ // PROPOSAL-084. The page is a mutable `blob/main` pointer, so it inherits
6641
+ // PROPOSAL-081's evergreen contract (MAINTAINERS.md § README Language Strategy):
6642
+ // it must stay usable by every published CLI that prints this line. The English
6643
+ // page is the runtime target and the zh page is one language-switch away, which
6644
+ // is the precedent `docs/upgrading.en.md` already set.
6645
+ const DOCTOR_UNCERTAINTY_DOC_URL = 'https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/doctor-uncertainty.en.md';
6646
+
6647
+ // PROPOSAL-084: the only way to build an `uncertain` finding. It exists so the
6648
+ // two fields the state MEANS cannot be dropped by a future caller copying the
6649
+ // bare `findings.push({ level, title, detail, action })` shape used everywhere
6650
+ // else in this file — the stable id (invariant 3) and the checks whose silence
6651
+ // must not be read as a pass (invariant 2). Omitting either throws here, in our
6652
+ // code, instead of rendering `undefined` into a user's health report. A crash
6653
+ // over a silently wrong answer is this subsystem's standing trade (see
6654
+ // `parseContextLine`'s type check for the same call).
6655
+ function uncertainFinding({ id, title, detail, affects, action }) {
6656
+ if (!id || !Array.isArray(affects) || affects.length === 0) {
6657
+ throw new Error(`PROPOSAL-084: an uncertain finding needs a stable id and a non-empty affects list (got id=${JSON.stringify(id)}, affects=${JSON.stringify(affects)})`);
6658
+ }
6659
+ return { level: 'uncertain', id, title, detail, affects, action };
6660
+ }
6661
+
3300
6662
  function printDoctorReport(stdout, cwd, findings) {
3301
6663
  stdout.write(`Dflow Doctor ${pkg.version}\n`);
3302
6664
  stdout.write(`Project: ${cwd}\n\n`);
3303
6665
 
3304
- if (findings.length === 0) {
6666
+ const uncertain = findings.filter((f) => f.level === 'uncertain');
6667
+ const assessed = findings.filter((f) => f.level !== 'uncertain');
6668
+
6669
+ // ⚠⚠ PROPOSAL-084 INVARIANT 1 — this branch is the whole proposal.
6670
+ // `All checks passed` is a claim about the WHOLE project. An uncertain finding
6671
+ // says a part of it could not be read at all, so the claim is not available —
6672
+ // not "available with a caveat printed underneath". The reviewer that was asked
6673
+ // to argue AGAINST this proposal made that the condition of its worth: a
6674
+ // disclosure the user can skim past just moves the silent pass from a wrong
6675
+ // `current` to an unread warning.
6676
+ //
6677
+ // ⚠ BE EXACT ABOUT WHAT THIS SPELLING BUYS, because the first version of this
6678
+ // comment overclaimed and the probe caught it. Against the ORIGINAL
6679
+ // `findings.length === 0` the guard is not stronger — it is logically the same
6680
+ // test, since `findings` is the union of the two arrays, and no fixture can
6681
+ // separate them. What it defends is the refactor this very function just made
6682
+ // tempting: now that `assessed` exists and reads like "the real findings", the
6683
+ // natural shorthand here is `assessed.length === 0`, and THAT would print a
6684
+ // clean bill of health over a project whose only finding is uncertain — the
6685
+ // precise false claim this proposal exists to delete. `test/upgrade-drift.mjs`
6686
+ // pins it with a fixture whose findings are uncertain and nothing else, which
6687
+ // is the case that distinguishes those two, and the pin was verified to go red
6688
+ // against the `assessed.length === 0` mutation rather than assumed to.
6689
+ if (uncertain.length === 0 && assessed.length === 0) {
3305
6690
  stdout.write('All checks passed. No Dflow health findings detected.\n');
6691
+ writeAdapterScopeNote(stdout);
6692
+ stdout.write('Doctor is read-only and does not modify any files.\n');
3306
6693
  return;
3307
6694
  }
3308
6695
 
3309
- for (const finding of findings) {
6696
+ for (const finding of assessed) {
3310
6697
  stdout.write(`[${finding.level}] ${finding.title}\n`);
3311
6698
  stdout.write(` ${finding.detail}\n`);
3312
6699
  stdout.write(` ${finding.action}\n\n`);
3313
6700
  }
3314
6701
 
6702
+ for (const finding of uncertain) {
6703
+ stdout.write(`[uncertain] ${finding.title} (${finding.id})\n`);
6704
+ stdout.write(` ${finding.detail}\n`);
6705
+ // ⚠ INVARIANT 2, and it is doing more work than it looks like. Doctor reports
6706
+ // by exception: a check that emits nothing reads as "that part is current".
6707
+ // For the checks below, that silence is precisely what has stopped being
6708
+ // trustworthy, so naming them converts an absence the user would have
6709
+ // misread into a statement they can act on. Without this line the feature
6710
+ // degrades into "something somewhere may be wrong".
6711
+ // ⚠⚠ "Not evaluated" WAS A FALSE STATEMENT (`p084-xv6` finding 1). The affected
6712
+ // checks still run — a user can see a `[warn]` from a check this very report
6713
+ // called unevaluated, which is a contradiction printed at the exact boundary
6714
+ // this feature exists to be honest about. What is true is weaker and is what
6715
+ // this now says: they ran, and neither their silence nor their output can be
6716
+ // trusted while the shape is present.
6717
+ stdout.write(` Ran, but cannot be trusted while this shape is present — their silence is NOT a pass, and anything they DO report may be an artefact of the shape: ${finding.affects.join('; ')}.\n`);
6718
+ stdout.write(` ${finding.action}\n\n`);
6719
+ }
6720
+
3315
6721
  const counts = { warn: 0, info: 0 };
3316
6722
  for (const f of findings) counts[f.level] = (counts[f.level] || 0) + 1;
3317
- stdout.write(`${findings.length} finding(s): ${counts.warn} warn, ${counts.info} info.\n`);
6723
+ const uncertainTally = counts.uncertain ? `, ${counts.uncertain} uncertain` : '';
6724
+ stdout.write(`${findings.length} finding(s): ${counts.warn} warn, ${counts.info} info${uncertainTally}.\n`);
6725
+ if (uncertain.length > 0) {
6726
+ stdout.write(`\nThis report is INCOMPLETE. Dflow found ${uncertain.length} shape(s) it is known to read unreliably, so the results of the checks named above — including their silence — cannot be trusted.\n`);
6727
+ stdout.write(`What each id means, and how to rewrite around it: ${DOCTOR_UNCERTAINTY_DOC_URL} (offline copy: docs/doctor-uncertainty.en.md in the installed package).\n`);
6728
+ }
6729
+ writeAdapterScopeNote(stdout);
3318
6730
  stdout.write('Doctor is read-only and does not modify any files.\n');
3319
6731
  }
3320
6732
 
6733
+ // PROPOSAL-091 (d) — user decision, 2026-08-29. Two rules, and MAINTAINERS.md
6734
+ // § Rule Type requires both to be labelled for what they are:
6735
+ //
6736
+ // BOUNDARY — doctor states what it cannot judge. `checkAdapterAndSkillState`
6737
+ // reports only on adapter and skill files that already exist;
6738
+ // whether this project SHOULD have them is intent, and intent is
6739
+ // recorded nowhere on disk.
6740
+ // DELEGATION — it names who picks the question up: the AI running Dflow, which
6741
+ // unlike doctor can simply ask.
6742
+ //
6743
+ // ⚠⚠ THE PARENTHESIS IS LOAD-BEARING AND MUST NOT BE DROPPED. Without it this
6744
+ // reads as a gate — "doctor does not check it, but the AI does, so it is
6745
+ // covered" — and nothing enforces that the AI checks, nor verifies afterwards
6746
+ // that it did. Describing a delegation as a guarantee is a defect this repo has
6747
+ // already paid to remove.
6748
+ //
6749
+ // ⚠ It prints on every run, and that placement is the whole reason it is cheap:
6750
+ // the moment someone asks "is my install complete?" IS the moment doctor is
6751
+ // running. The alternatives considered and rejected were the flow files (every
6752
+ // feature run pays) and `AI-AGENT-GUIDE.md` (every session pays).
6753
+ function writeAdapterScopeNote(stdout) {
6754
+ // ⚠⚠ "their absence is never a finding here" WAS FALSE, and it was printed
6755
+ // directly beneath a finding that contradicted it: with 10 of 11 adapters on
6756
+ // disk, the same report names the missing one. Absence of a WHOLE surface is
6757
+ // what goes unreported; absence of some members of a surface already in use is
6758
+ // reported, and has to be, or the partial-set row would not exist. A scope note
6759
+ // that overstates its own silence is the same defect as a check that overstates
6760
+ // its own coverage — this note exists to be exact about the boundary.
6761
+ // ⚠ "reported above" was wrong for the same reason the sentence before it was:
6762
+ // this note is fixed text on every run, so any claim it makes about THIS
6763
+ // report is false on the runs where that finding is not present. It is a
6764
+ // statement of policy; keep it one.
6765
+ stdout.write('\nScope: doctor does not judge whether this project SHOULD have the `/dflow:*` command files or a Dflow skill file — that is intent, and nothing records it. It judges only surfaces already in use: a partly installed set is reported, while a surface with nothing installed at all is passed over in silence rather than reported as missing.\n');
6766
+ stdout.write('If you are unsure, ask the AI you run Dflow with to check this project once against the command list in AI-AGENT-GUIDE.md.\n');
6767
+ stdout.write('(That is a delegation, not a guarantee: nothing makes it happen, and nothing verifies that it did.)\n');
6768
+ }
6769
+
3321
6770
  module.exports = {
3322
6771
  runConfigureAgents,
3323
6772
  runDoctor,
@@ -3331,7 +6780,34 @@ module.exports = {
3331
6780
  writeFilePlan,
3332
6781
  // Exported for tests (PROPOSAL-064): pure bundle-source guards, unit-tested on
3333
6782
  // synthetic descriptor lists without touching the packaged templates/ tree.
6783
+ // REQUIRED_COMMON_BUNDLE_FILES rides along so the tests can derive their
6784
+ // fixtures from the real list instead of restating it: a hardcoded copy goes
6785
+ // stale the moment a file joins the list, and it fails as a confusing
6786
+ // assertion error in the *positive* case rather than as missing coverage.
6787
+ REQUIRED_COMMON_BUNDLE_FILES,
3334
6788
  assertNoBundleCollision,
3335
6789
  assertEditionBundleComplete,
3336
- assertCommonBundleComplete
6790
+ assertCommonBundleComplete,
6791
+ // Exported for tests (PROPOSAL-076): context inference reads the guide's
6792
+ // Project Context rows, but its only write consumer is whole-guide creation
6793
+ // when the guide is missing — the real-value read has no black-box write to
6794
+ // observe, so tests call these directly.
6795
+ // Exported for tests (PROPOSAL-090): the canonical-region substitution-free
6796
+ // guard must be answer-independent — see placeholderTokens.
6797
+ placeholderTokens,
6798
+ inferTechStackSummary,
6799
+ inferMigrationContext,
6800
+ // Exported for tests (PROPOSAL-084): the id set is the contract between a
6801
+ // shipped `[uncertain]` line and the explainer page it sends the reader to.
6802
+ // Derived from the detector registry rather than retyped, so a new detector
6803
+ // cannot be added without the docs guard noticing it.
6804
+ DOCTOR_UNCERTAINTY_IDS,
6805
+ // Exported for tests (PROPOSAL-091): the route-(B) gates only take their
6806
+ // multi-candidate path when the edition is uninferable, and a fixture cannot
6807
+ // observe that through doctor's output — an unknown edition and a known one
6808
+ // reach the same finding, only the wording differs. The first version of that
6809
+ // fixture removed one of the three structural signals and stayed on the
6810
+ // single-candidate path while claiming to test the other, so the test asserts
6811
+ // the precondition directly instead of assuming it.
6812
+ inferProjectBundleEdition
3337
6813
  };