dflow-sdd-ddd 0.13.0 → 0.14.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.
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,13 @@ 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';
26
34
  const WORKFLOW_BUNDLE_DEST = 'dflow/specs/shared/dflow-workflows';
27
35
  const WORKFLOW_BUNDLE_MANIFEST_PATH = `${WORKFLOW_BUNDLE_DEST}/.dflow-bundle-manifest.json`;
28
36
  const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
@@ -367,14 +375,47 @@ async function runConfigureAgents(options = {}) {
367
375
  }
368
376
  }
369
377
 
370
- const plan = await buildConfigureAgentsPlan(cwd, {
378
+ // PROPOSAL-058: adoption offers are decided by planning the run twice. The
379
+ // first plan flags what is offer-able (a recognizable pre-marker guide, a
380
+ // guide-referencing agent file Dflow does not manage); an interactive run
381
+ // then asks, and only a granted consent triggers a re-plan. A non-TTY run
382
+ // never asks and never consumes a stdin slot (the PROPOSAL-074 contract:
383
+ // existing piped answer sequences must run unchanged), so it keeps the
384
+ // skip + warn behavior. Deriving the offers from the plan itself keeps the
385
+ // question conditions and the plan branches from ever drifting apart.
386
+ const interactive = Boolean(stdin.isTTY && stdout.isTTY);
387
+ let adoptGuideMarkers = false;
388
+ const adoptShimAgents = [];
389
+ const buildPlan = () => buildConfigureAgentsPlan(cwd, {
371
390
  ...projectContext,
372
391
  aiAgents,
373
392
  commandAdapters: Boolean(options.commandAdapters),
374
393
  skills: Boolean(options.skills),
375
- skillAgents
394
+ skillAgents,
395
+ adoptGuideMarkers,
396
+ adoptShimAgents
376
397
  });
377
398
 
399
+ let plan = await buildPlan();
400
+
401
+ if (interactive) {
402
+ const offersGuide = plan.items.some((item) => item.offerGuideAdoption);
403
+ const shimOffers = dedupe(
404
+ plan.items.filter((item) => item.offerShimAdoption).map((item) => item.offerShimAdoption)
405
+ );
406
+ if (offersGuide) {
407
+ adoptGuideMarkers = await askGuideMarkerAdoption(rl, stdout);
408
+ }
409
+ for (const agent of shimOffers) {
410
+ if (await askShimBlockAdoption(rl, stdout, agent)) {
411
+ adoptShimAgents.push(agent);
412
+ }
413
+ }
414
+ if (adoptGuideMarkers || adoptShimAgents.length > 0) {
415
+ plan = await buildPlan();
416
+ }
417
+ }
418
+
378
419
  const warnings = plan.warnings || [];
379
420
  renderPreview(stdout, plan, warnings);
380
421
  const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
@@ -752,18 +793,19 @@ async function inferProjectContext(cwd, rl, stdout, stderr) {
752
793
  };
753
794
  }
754
795
 
796
+ // The machine-readable line patterns and value parse live in lib/doctor-checks.js
797
+ // so the doctor "machine format" findings and this inference can never drift
798
+ // apart (PROPOSAL-058).
755
799
  async function inferGitPolicy(cwd) {
756
800
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
757
801
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
758
- const match = content.match(/Selected Git policy:\s*`([^`]+)`/);
759
- return match ? match[1] : null;
802
+ return doctorChecks.parseContextLine(content, doctorChecks.GIT_POLICY_LINE_RE);
760
803
  }
761
804
 
762
805
  async function inferAiCommitMarker(cwd) {
763
806
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
764
807
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
765
- const match = content.match(/AI commit marker:\s*`([^`]+)`/);
766
- return match ? match[1] : null;
808
+ return doctorChecks.parseContextLine(content, doctorChecks.AI_COMMIT_MARKER_LINE_RE);
767
809
  }
768
810
 
769
811
  async function inferExistingEdition(cwd) {
@@ -782,22 +824,43 @@ async function inferExistingEdition(cwd) {
782
824
  async function inferProseLanguage(cwd) {
783
825
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
784
826
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
785
- const match = content.match(/Project prose language:\s*`([^`]+)`/);
786
- return match ? match[1] : 'unknown';
827
+ return doctorChecks.parseContextLine(content, doctorChecks.PROSE_LANGUAGE_LINE_RE) ?? 'unknown';
787
828
  }
788
829
 
830
+ // PROPOSAL-076: the machine-readable record of the init Q2/Q3 answers is the
831
+ // guide's "## Project Context" table — init substitutes them into the
832
+ // `| Tech stack |` / `| Migration / legacy context |` rows there. (The
833
+ // pre-076 code looked for those rows in _overview.md, which never carried
834
+ // them in any packaged template, so inference always fell back.) Only the
835
+ // Project Context section is parsed: it is the contractual user region
836
+ // (PROPOSAL-058), so a same-name row anywhere else in the guide can never
837
+ // shadow it.
789
838
  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';
839
+ return (await inferGuideProjectContextValue(cwd, doctorChecks.TECH_STACK_ROW_RE)) ?? 'unknown';
794
840
  }
795
841
 
796
842
  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';
843
+ return (await inferGuideProjectContextValue(cwd, doctorChecks.MIGRATION_CONTEXT_ROW_RE)) ?? 'none';
844
+ }
845
+
846
+ async function inferGuideProjectContextValue(cwd, re) {
847
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
848
+ const content = await fs.readFile(guidePath, 'utf8').catch(() => '');
849
+ const slice = projectContextParseSlice(content.replace(/\r\n/g, '\n'));
850
+ return slice === null ? null : doctorChecks.parseContextLine(slice, re);
851
+ }
852
+
853
+ // The parseable slice of the "## Project Context" section: fenced examples
854
+ // inside the section are blanked so a decoy row in a fence can neither supply
855
+ // nor shadow a value (PROPOSAL-076 gate G2) — fence state is clean at the
856
+ // heading because the fence-aware bounds scan would not have matched a heading
857
+ // inside a fence. Inference and the doctor row check must both parse through
858
+ // this slice so they can never disagree.
859
+ function projectContextParseSlice(lfContent) {
860
+ const bounds = projectContextSectionBounds(lfContent);
861
+ if (!bounds) return null;
862
+ const section = bounds.lines.slice(bounds.start, bounds.end).join('\n');
863
+ return doctorChecks.blankFencedBlocks(section).join('\n');
801
864
  }
802
865
 
803
866
  async function askSelect(rl, stdout, stderr, config) {
@@ -1221,19 +1284,18 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1221
1284
  const items = [];
1222
1285
  const warnings = [];
1223
1286
 
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
- });
1287
+ let packagedGuide = await readPackagedTemplate(answers.edition, 'scaffolding/AI-AGENT-GUIDE.md');
1288
+ packagedGuide = substitutePlaceholders(packagedGuide, substitution);
1289
+ await addCanonicalGuideItem(cwd, items, warnings, packagedGuide, answers);
1232
1290
 
1233
- const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(content) : [];
1291
+ const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(packagedGuide) : [];
1234
1292
 
1235
1293
  for (const agent of answers.aiAgents) {
1236
- await addAiAgentShim(cwd, items, agent, substitution, { commandRegistry, warnings });
1294
+ await addAiAgentShim(cwd, items, agent, substitution, {
1295
+ commandRegistry,
1296
+ warnings,
1297
+ adoptShimAgents: answers.adoptShimAgents || []
1298
+ });
1237
1299
  }
1238
1300
 
1239
1301
  if (answers.commandAdapters) {
@@ -1260,6 +1322,15 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1260
1322
  await addWorkflowBundleItems(cwd, items, bundleWarnings, answers.edition);
1261
1323
  warnings.push(...bundleWarnings);
1262
1324
 
1325
+ // Last plan item on purpose: the write phase runs in plan order and aborts on
1326
+ // the first failure, so the last-reconciled version line only advances when
1327
+ // everything this run re-projects was written. Guarded skips do not abort the
1328
+ // write phase, so the item additionally carries requiresFullApply — the write
1329
+ // phase drops it (with a warning) when any earlier planned change was skipped
1330
+ // unexpectedly (changed after preview, vanished target, unexpected existing
1331
+ // target).
1332
+ await addConventionsVersionReconcileItem(cwd, items);
1333
+
1263
1334
  return {
1264
1335
  items,
1265
1336
  deferred: [],
@@ -1623,6 +1694,219 @@ async function readPackagedBundleFile(sourceRoot, sourceRel) {
1623
1694
  }
1624
1695
  }
1625
1696
 
1697
+ // PROPOSAL-058: the canonical AI agent guide is user-owned (it embeds the
1698
+ // project's "## Project Context"), but most of its body is Dflow-canonical
1699
+ // content that upgrades must be able to refresh — a guide frozen at its init
1700
+ // version leaves the re-projected workflow bundle § referencing sections the
1701
+ // guide does not have. The packaged template wraps the canonical body in
1702
+ // guide-canonical START/END markers. Decision table for an existing guide
1703
+ // (user decision 2026-06-08: skip + warn + offer; never rewrite unasked):
1704
+ // - well-formed markers -> replace the marked region in place (idempotent)
1705
+ // - no markers, recognizable -> skip + warn, and flag the item so an
1706
+ // interactive run can offer marker adoption. Adoption replaces everything
1707
+ // outside "## Project Context" with this version's canonical guide content;
1708
+ // it is consent-gated because historical canonical text cannot be verified
1709
+ // against the current package — only the user knows whether they customized
1710
+ // sections outside Project Context.
1711
+ // - no markers, unrecognizable -> skip + warn
1712
+ // - malformed marker pair -> skip + warn (never guess)
1713
+ async function addCanonicalGuideItem(cwd, items, warnings, packagedGuide, answers) {
1714
+ const source = `packaged:${answers.edition}/scaffolding/AI-AGENT-GUIDE.md`;
1715
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
1716
+
1717
+ if (!(await pathExists(guidePath))) {
1718
+ items.push({
1719
+ relativePath: AI_AGENT_GUIDE_DEST,
1720
+ source,
1721
+ notes: 'canonical AI agent guide',
1722
+ content: packagedGuide
1723
+ });
1724
+ return;
1725
+ }
1726
+
1727
+ const packagedRegion = classifyMarkedRegion(
1728
+ packagedGuide,
1729
+ GUIDE_CANONICAL_SECTION_START,
1730
+ GUIDE_CANONICAL_SECTION_END
1731
+ );
1732
+ if (packagedRegion.state !== 'present') {
1733
+ throw new InitError('Internal error: packaged AI-AGENT-GUIDE.md has no well-formed guide-canonical markers.');
1734
+ }
1735
+
1736
+ const existingContent = await fs.readFile(guidePath, 'utf8');
1737
+ const eol = detectDominantEol(existingContent);
1738
+ const lf = existingContent.replace(/\r\n/g, '\n');
1739
+ const region = classifyMarkedRegion(lf, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
1740
+
1741
+ const skipItem = (notes) => {
1742
+ items.push({
1743
+ relativePath: AI_AGENT_GUIDE_DEST,
1744
+ source,
1745
+ notes,
1746
+ content: packagedGuide,
1747
+ action: 'skip',
1748
+ intentionalSkip: true,
1749
+ size: Buffer.byteLength(packagedGuide, 'utf8')
1750
+ });
1751
+ };
1752
+
1753
+ if (region.state === 'present') {
1754
+ const refreshed =
1755
+ lf.slice(0, region.startIdx) +
1756
+ packagedGuide.slice(packagedRegion.startIdx, packagedRegion.endIdx) +
1757
+ lf.slice(region.endIdx);
1758
+ pushRootInjectItem(items, {
1759
+ relativePath: AI_AGENT_GUIDE_DEST,
1760
+ source,
1761
+ notes: 'refreshed Dflow-canonical guide content (content outside the markers kept)',
1762
+ content: applyEol(refreshed, eol),
1763
+ expectedContent: existingContent
1764
+ });
1765
+ return;
1766
+ }
1767
+
1768
+ if (region.state === 'malformed') {
1769
+ warnings.push(
1770
+ `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.`
1771
+ );
1772
+ skipItem('canonical AI agent guide, malformed guide-canonical markers; left untouched');
1773
+ return;
1774
+ }
1775
+
1776
+ if (isRecognizableDflowGuide(lf)) {
1777
+ if (answers.adoptGuideMarkers) {
1778
+ pushRootInjectItem(items, {
1779
+ relativePath: AI_AGENT_GUIDE_DEST,
1780
+ source,
1781
+ notes: 'adopted guide-canonical markers (kept your "## Project Context" section)',
1782
+ content: applyEol(transplantProjectContext(packagedGuide, lf), eol),
1783
+ expectedContent: existingContent
1784
+ });
1785
+ return;
1786
+ }
1787
+ warnings.push(
1788
+ `${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\`.`
1789
+ );
1790
+ const item = 'canonical AI agent guide, no guide-canonical markers; left untouched';
1791
+ skipItem(item);
1792
+ items[items.length - 1].offerGuideAdoption = true;
1793
+ return;
1794
+ }
1795
+
1796
+ warnings.push(
1797
+ `Existing ${AI_AGENT_GUIDE_DEST} is not recognizable as a Dflow guide; left it untouched.`
1798
+ );
1799
+ skipItem('canonical AI agent guide, not recognizable as a Dflow guide; left untouched');
1800
+ }
1801
+
1802
+ // Fence-aware, and deliberately the same predicate transplantProjectContext
1803
+ // relies on: recognizability must imply locatable Project Context bounds, or
1804
+ // an accepted adoption offer would abort on the internal error below
1805
+ // (PROPOSAL-076 gate G3 — a guide whose only "## Project Context" heading sat
1806
+ // inside a fenced example was offered adoption and then crashed the run).
1807
+ function isRecognizableDflowGuide(lfContent) {
1808
+ const content = lfContent.replace(/^\uFEFF/, ''); // a BOM must not defeat the title line (gate G4)
1809
+ return doctorChecks.blankFencedBlocks(content).some((line) => /^# Dflow AI Agent Guide\s*$/.test(line)) &&
1810
+ projectContextSectionBounds(content) !== null;
1811
+ }
1812
+
1813
+ // Bounds of the "## Project Context" section in LF content: the heading line up
1814
+ // to (exclusive) the next "## " heading, the guide-canonical START marker, or EOF.
1815
+ // Fence-aware (PROPOSAL-076 gate G1): headings or markers inside ``` / ~~~
1816
+ // examples are content, not structure — the boundary scan runs on a
1817
+ // fence-blanked shadow while the returned lines stay the real content.
1818
+ function projectContextSectionBounds(lfContent) {
1819
+ const stripped = lfContent.replace(/^\uFEFF/, ''); // keep BOM out of the line-0 heading match (gate G4)
1820
+ const lines = stripped.split('\n');
1821
+ const scan = doctorChecks.blankFencedBlocks(stripped);
1822
+ const start = scan.findIndex((line) => /^## Project Context\s*$/.test(line));
1823
+ if (start < 0) return null;
1824
+ let end = scan.length;
1825
+ for (let i = start + 1; i < scan.length; i += 1) {
1826
+ if (/^## /.test(scan[i]) || scan[i].startsWith(GUIDE_CANONICAL_SECTION_START)) {
1827
+ end = i;
1828
+ break;
1829
+ }
1830
+ }
1831
+ return { lines, start, end };
1832
+ }
1833
+
1834
+ // PROPOSAL-058 bootstrap: rebuild the guide from the packaged template, carrying
1835
+ // over the project's own "## Project Context" section (trailing blank lines normalized; the only
1836
+ // user-specific region by contract). Everything else — including any prose the
1837
+ // user kept outside Project Context — is replaced; the interactive offer says so
1838
+ // and defaults to No.
1839
+ function transplantProjectContext(packagedGuide, existingLf) {
1840
+ const existing = projectContextSectionBounds(existingLf);
1841
+ const packaged = projectContextSectionBounds(packagedGuide);
1842
+ if (!existing || !packaged) {
1843
+ throw new InitError('Internal error: cannot locate "## Project Context" while adopting guide markers.');
1844
+ }
1845
+ const existingSection = existing.lines.slice(existing.start, existing.end);
1846
+ while (existingSection.length > 0 && existingSection[existingSection.length - 1].trim() === '') {
1847
+ existingSection.pop();
1848
+ }
1849
+ return [
1850
+ ...packaged.lines.slice(0, packaged.start),
1851
+ ...existingSection,
1852
+ '',
1853
+ ...packaged.lines.slice(packaged.end)
1854
+ ].join('\n');
1855
+ }
1856
+
1857
+ // PROPOSAL-058: the interactive adoption questions. Only a TTY run ever asks
1858
+ // (the PROPOSAL-074 non-TTY contract: never consume a stdin slot); blank answers
1859
+ // mean No because both offers rewrite a user-owned file.
1860
+ async function askGuideMarkerAdoption(rl, stdout) {
1861
+ stdout.write(
1862
+ `\nYour ${AI_AGENT_GUIDE_DEST} predates Dflow's managed canonical markers, so\n` +
1863
+ 'upgrades cannot refresh its canonical sections in place. Adopting the markers\n' +
1864
+ 'replaces everything OUTSIDE "## Project Context" with this Dflow version\'s\n' +
1865
+ 'canonical guide content (your Project Context section is kept).\n' +
1866
+ 'Answer N if you customized any other guide section.\n'
1867
+ );
1868
+ return askConfirmation(rl, 'Adopt the managed guide markers now? (y/N) ');
1869
+ }
1870
+
1871
+ async function askShimBlockAdoption(rl, stdout, agent) {
1872
+ const relativePath = getAiAgentTarget(agent).relativePath;
1873
+ stdout.write(
1874
+ `\n${relativePath} references the Dflow guide but is not Dflow-managed (no markers),\n` +
1875
+ 'so upgrades cannot refresh any Dflow wording inside it. Dflow can append its\n' +
1876
+ 'managed, marker-delimited block at the end of the file; afterwards remove any\n' +
1877
+ 'older Dflow wording you keep above the block.\n'
1878
+ );
1879
+ return askConfirmation(rl, `Append the managed Dflow block to ${relativePath}? (y/N) `);
1880
+ }
1881
+
1882
+ // PROPOSAL-058 (user decision 2026-06-08, OQ2): `> Dflow Version:` in
1883
+ // _conventions.md means "the Dflow version this project last reconciled with",
1884
+ // so a successful configure-agents run must advance it — before this it froze at
1885
+ // the init version, which was a bug, not a design. Narrow single-line rewrite of
1886
+ // a user-owned file: previewed like every plan item, guarded by the rootInject
1887
+ // raw-equality check, and never *added* when the line is absent (doctor reports
1888
+ // that case instead).
1889
+ async function addConventionsVersionReconcileItem(cwd, items) {
1890
+ const relativePath = 'dflow/specs/shared/_conventions.md';
1891
+ const absolute = path.join(cwd, relativePath);
1892
+ if (!(await pathExists(absolute))) return;
1893
+ const existingContent = await fs.readFile(absolute, 'utf8');
1894
+ const lf = existingContent.replace(/\r\n/g, '\n');
1895
+ const match = lf.match(/^> Dflow Version:[ \t]*(.*)$/m);
1896
+ if (!match || match[1].trim() === pkg.version) return;
1897
+ const eol = detectDominantEol(existingContent);
1898
+ const updated = lf.replace(/^> Dflow Version:[ \t]*.*$/m, `> Dflow Version: ${pkg.version}`);
1899
+ pushRootInjectItem(items, {
1900
+ relativePath,
1901
+ source: 'generated:dflow-version-reconcile',
1902
+ notes: `update Dflow Version line to ${pkg.version} (last reconciled)`,
1903
+ content: applyEol(updated, eol),
1904
+ expectedContent: existingContent
1905
+ });
1906
+ // See the call-site comment: never advance the line over a guarded skip.
1907
+ items[items.length - 1].requiresFullApply = true;
1908
+ }
1909
+
1626
1910
  // PROPOSAL-054: configure a tool's root agent file. A non-guide existing file used
1627
1911
  // to be parked as a side merge snippet ("hand-merge this yourself"); it is now an
1628
1912
  // auto-injected, marker-delimited Dflow block shown in the confirmation preview.
@@ -1779,7 +2063,40 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1779
2063
  // marker-managed (a guide-configured file the user wrote / heavily edited). Keep
1780
2064
  // the base shim skipped so we never duplicate their guide pointer. Under Codex
1781
2065
  // --command-adapters still install / update the self-delimited trigger block (OQ#6c).
2066
+ // PROPOSAL-058: any Dflow wording inside such a file is frozen (old canonical
2067
+ // prose and user prose cannot be told apart mechanically), so an interactive run
2068
+ // offers to append the managed block instead — with consent the file becomes
2069
+ // marker-managed (future runs refresh it via case 2b) and the user removes their
2070
+ // older Dflow wording; without consent (or non-TTY) the 2d behavior is unchanged.
1782
2071
  if (contentReferencesAiAgentGuide(existingContent)) {
2072
+ const adoptShimAgents = options.adoptShimAgents || [];
2073
+ if (adoptShimAgents.includes(agent)) {
2074
+ let updated = appendBlockLf(lf, agentShimBlock);
2075
+ if (wantsTrigger) {
2076
+ updated = upsertCodexTriggerBlock(updated, triggerBlock);
2077
+ }
2078
+ if (warnings) {
2079
+ warnings.push(
2080
+ `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.`
2081
+ );
2082
+ }
2083
+ pushRootInjectItem(items, {
2084
+ relativePath: target.relativePath,
2085
+ source,
2086
+ notes: `selected, appended managed Dflow block to existing ${target.relativePath}`,
2087
+ content: applyEol(updated, eol),
2088
+ expectedContent: existingContent
2089
+ });
2090
+ return;
2091
+ }
2092
+ // No consent (declined, or a non-interactive run): keep the 2d skip, but say
2093
+ // so — the docs promise "skip and warn", and the frozen Dflow wording is
2094
+ // exactly the drift this proposal makes visible.
2095
+ if (warnings) {
2096
+ warnings.push(
2097
+ `${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).`
2098
+ );
2099
+ }
1783
2100
  if (wantsTrigger) {
1784
2101
  pushRootInjectItem(items, {
1785
2102
  relativePath: target.relativePath,
@@ -1788,6 +2105,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1788
2105
  content: applyEol(upsertCodexTriggerBlock(lf, triggerBlock), eol),
1789
2106
  expectedContent: existingContent
1790
2107
  });
2108
+ items[items.length - 1].offerShimAdoption = agent;
1791
2109
  } else {
1792
2110
  items.push({
1793
2111
  relativePath: target.relativePath,
@@ -1796,6 +2114,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1796
2114
  content: fullShim,
1797
2115
  action: 'skip',
1798
2116
  intentionalSkip: true,
2117
+ offerShimAdoption: agent,
1799
2118
  size: Buffer.byteLength(fullShim, 'utf8')
1800
2119
  });
1801
2120
  }
@@ -2517,8 +2836,13 @@ function buildSubstitutionMap(cwd, answers) {
2517
2836
  ['{系統名稱}', systemName],
2518
2837
  ['{project-type}', answers.projectType],
2519
2838
  ['{edition}', answers.edition],
2520
- ['{tech-stack-summary}', answers.techStackSummary],
2521
- ['{migration-context}', answers.migrationContext],
2839
+ // These two land in the guide's "## Project Context" table cells (their
2840
+ // only template use), so bare `|` in the free-text answers must be escaped
2841
+ // or the row gains phantom cells and inference later truncates the value —
2842
+ // escapeTableCell is the exact inverse of parseContextLine's unescape
2843
+ // (PROPOSAL-076 gate G2 round-trip fix).
2844
+ ['{tech-stack-summary}', escapeTableCell(answers.techStackSummary)],
2845
+ ['{migration-context}', escapeTableCell(answers.migrationContext)],
2522
2846
  ['{prose-language}', answers.proseLanguage],
2523
2847
  ['{dflow-version}', pkg.version],
2524
2848
  ['{Language}', extracted.language || '{Language}'],
@@ -2861,10 +3185,28 @@ async function writeFilePlan(cwd, plan) {
2861
3185
  warnings: []
2862
3186
  };
2863
3187
 
3188
+ // PROPOSAL-058: an item flagged requiresFullApply (the `> Dflow Version:`
3189
+ // last-reconciled advance) may only run when every previewed change actually
3190
+ // applied. Guarded skips — changed-after-preview, vanished / non-file
3191
+ // targets, unexpected existing targets — mean the previewed reconciliation
3192
+ // is incomplete, so the flagged item is skipped with a warning instead of
3193
+ // overstating the reconciled version. Intentional skips (already current /
3194
+ // already configured) do not block it, and neither does removing a stale
3195
+ // file that is already gone (the desired end state holds).
3196
+ let unexpectedSkip = false;
3197
+
2864
3198
  for (const item of plan.items) {
2865
3199
  const targetPath = path.join(cwd, item.relativePath);
2866
3200
 
2867
3201
  try {
3202
+ if (item.requiresFullApply && unexpectedSkip) {
3203
+ result.skipped.push(item.relativePath);
3204
+ result.warnings.push(
3205
+ `Skipped the Dflow Version update in ${item.relativePath} because earlier planned changes were skipped after the preview; re-run \`dflow configure-agents\`.`
3206
+ );
3207
+ continue;
3208
+ }
3209
+
2868
3210
  if (item.action === 'remove') {
2869
3211
  let stats;
2870
3212
  try {
@@ -2879,6 +3221,7 @@ async function writeFilePlan(cwd, plan) {
2879
3221
  }
2880
3222
 
2881
3223
  if (!stats.isFile()) {
3224
+ unexpectedSkip = true;
2882
3225
  result.skipped.push(item.relativePath);
2883
3226
  result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
2884
3227
  continue;
@@ -2886,6 +3229,7 @@ async function writeFilePlan(cwd, plan) {
2886
3229
 
2887
3230
  const currentContent = await fs.readFile(targetPath, 'utf8');
2888
3231
  if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
3232
+ unexpectedSkip = true;
2889
3233
  result.skipped.push(item.relativePath);
2890
3234
  result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
2891
3235
  continue;
@@ -2908,6 +3252,7 @@ async function writeFilePlan(cwd, plan) {
2908
3252
  stats = await fs.stat(targetPath);
2909
3253
  } catch (error) {
2910
3254
  if (error.code === 'ENOENT') {
3255
+ unexpectedSkip = true;
2911
3256
  result.skipped.push(item.relativePath);
2912
3257
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} no longer exists; re-run to inject the Dflow block.`);
2913
3258
  continue;
@@ -2915,12 +3260,14 @@ async function writeFilePlan(cwd, plan) {
2915
3260
  throw error;
2916
3261
  }
2917
3262
  if (!stats.isFile()) {
3263
+ unexpectedSkip = true;
2918
3264
  result.skipped.push(item.relativePath);
2919
3265
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} is no longer a regular file; re-run to inject the Dflow block.`);
2920
3266
  continue;
2921
3267
  }
2922
3268
  const currentRaw = await fs.readFile(targetPath, 'utf8');
2923
3269
  if (currentRaw !== item.expectedContent) {
3270
+ unexpectedSkip = true;
2924
3271
  result.skipped.push(item.relativePath);
2925
3272
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} changed after the preview; re-run to inject the Dflow block.`);
2926
3273
  continue;
@@ -2948,6 +3295,7 @@ async function writeFilePlan(cwd, plan) {
2948
3295
  // already-current Dflow block) is expected, not a problem — don't emit the
2949
3296
  // generic "skipped existing target" warning for it.
2950
3297
  if (!item.intentionalSkip) {
3298
+ unexpectedSkip = true;
2951
3299
  result.warnings.push(`Skipped existing target: ${item.relativePath}`);
2952
3300
  }
2953
3301
  if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
@@ -2989,6 +3337,10 @@ async function writeFilePlan(cwd, plan) {
2989
3337
  }
2990
3338
 
2991
3339
  if (targetExists) {
3340
+ // A previewed create raced against a concurrent creation — the
3341
+ // previewed change did not apply, so it blocks requiresFullApply
3342
+ // items exactly like the pre-write guard skips above.
3343
+ unexpectedSkip = true;
2992
3344
  result.skipped.push(item.relativePath);
2993
3345
  result.warnings.push(`Skipped existing target: ${item.relativePath}`);
2994
3346
  continue;
@@ -3206,7 +3558,16 @@ async function runDoctor(options = {}) {
3206
3558
 
3207
3559
  const findings = [];
3208
3560
  await checkConventionsDflowVersion(cwd, findings);
3561
+ await checkConventionsVersionReconciled(cwd, findings);
3562
+ await checkConventionsPolicyFormat(cwd, findings);
3563
+ await checkGuideCanonicalState(cwd, findings);
3564
+ await checkGuideProjectContextFormat(cwd, findings);
3565
+ await checkGuideSectionRefs(cwd, findings);
3566
+ await checkInitOnlyStarters(cwd, findings);
3567
+ await checkFeatureIndexShape(cwd, findings);
3568
+ await checkRootAgentShims(cwd, findings);
3209
3569
  await checkOrphanedWorkflowBundleFiles(cwd, findings);
3570
+ await checkBundleManifestVersion(cwd, findings);
3210
3571
 
3211
3572
  printDoctorReport(stdout, cwd, findings);
3212
3573
  return 0;
@@ -3234,6 +3595,412 @@ async function checkConventionsDflowVersion(cwd, findings) {
3234
3595
  }
3235
3596
  }
3236
3597
 
3598
+ // PROPOSAL-058 (user decision 2026-06-08, OQ2): `> Dflow Version:` records the
3599
+ // Dflow version this project last reconciled with (`dflow configure-agents`
3600
+ // advances it). Behind the CLI means the Dflow-managed layers may be stale and
3601
+ // the user-owned layers unreviewed since the upgrade.
3602
+ async function checkConventionsVersionReconciled(cwd, findings) {
3603
+ const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
3604
+ if (!(await pathExists(conventionsPath))) return;
3605
+ const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
3606
+ const match = content.match(/^> Dflow Version:[ \t]*(.*)$/m);
3607
+ if (!match) return; // absence is checkConventionsDflowVersion's finding
3608
+ const recorded = match[1].trim();
3609
+ // Prerelease suffixes are valid package versions (the smoke test's own
3610
+ // Dflow-Version assertion allows them), so they must not read as "not a
3611
+ // version" noise on a fresh init of a prerelease build.
3612
+ if (!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(recorded)) {
3613
+ findings.push({
3614
+ level: 'info',
3615
+ title: `_conventions.md Dflow Version line is not a plain x.y.z version: \`${recorded}\``,
3616
+ detail: 'The line records which Dflow version the project last reconciled with; doctor cannot compare this value against the CLI.',
3617
+ 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}).`
3618
+ });
3619
+ return;
3620
+ }
3621
+ if (recorded === pkg.version) return;
3622
+ if (compareVersions(recorded, pkg.version) < 0) {
3623
+ findings.push({
3624
+ level: 'info',
3625
+ title: `Project last reconciled with Dflow ${recorded}; this CLI is ${pkg.version}`,
3626
+ detail: 'Dflow-managed layers (workflow bundle, guide canonical content, adapters) may be stale, and user-owned layers may need review against the newer version.',
3627
+ action: 'Run `dflow configure-agents` to re-project the Dflow-managed layers and update the line, then review the upgrade caveat in the Dflow README for user-owned surfaces.'
3628
+ });
3629
+ } else {
3630
+ findings.push({
3631
+ level: 'info',
3632
+ title: `Project was reconciled with Dflow ${recorded}, newer than this CLI (${pkg.version})`,
3633
+ detail: 'Running an older CLI against a newer project layout can re-project older content over newer files.',
3634
+ action: 'Upgrade the dflow package before re-running `dflow init` / `dflow configure-agents` here.'
3635
+ });
3636
+ }
3637
+ }
3638
+
3639
+ // PROPOSAL-058 direction 2 (b): the policy sections are machine-read — context
3640
+ // inference for configure-agents parses the exact line formats in
3641
+ // lib/doctor-checks.js. A section that drifted from the canonical format makes
3642
+ // inference return null, and code paths that need a policy default a null to
3643
+ // `trunk` / `none`, so drift here risks a silent policy flip on any future
3644
+ // re-projection that writes these sections.
3645
+ async function checkConventionsPolicyFormat(cwd, findings) {
3646
+ const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
3647
+ if (!(await pathExists(conventionsPath))) return;
3648
+ const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
3649
+
3650
+ const sections = [
3651
+ {
3652
+ heading: '## Git Policy',
3653
+ re: doctorChecks.GIT_POLICY_LINE_RE,
3654
+ values: doctorChecks.GIT_POLICY_VALUES,
3655
+ example: 'Selected Git policy: `gitflow`',
3656
+ nullEffect: 'a null Git policy defaults to `trunk` wherever a policy value is required'
3657
+ },
3658
+ {
3659
+ heading: '## AI Commit Policy',
3660
+ re: doctorChecks.AI_COMMIT_MARKER_LINE_RE,
3661
+ values: doctorChecks.AI_COMMIT_MARKER_VALUES,
3662
+ example: 'AI commit marker: `none`',
3663
+ nullEffect: 'an unrecognized marker value falls back to `none`'
3664
+ },
3665
+ {
3666
+ heading: '## Prose Language',
3667
+ re: doctorChecks.PROSE_LANGUAGE_LINE_RE,
3668
+ values: null,
3669
+ example: 'Project prose language: `en`',
3670
+ nullEffect: 'prose-generating flows lose the project language setting'
3671
+ }
3672
+ ];
3673
+
3674
+ for (const section of sections) {
3675
+ if (!new RegExp(`^${section.heading}\\s*$`, 'm').test(content)) {
3676
+ findings.push({
3677
+ level: 'warn',
3678
+ title: `_conventions.md is missing the ${section.heading} section`,
3679
+ detail: 'Newer Dflow init projects always carry it; existing projects are not auto-migrated (user-owned file).',
3680
+ action: `Copy the section from a fresh \`dflow init\` project and set your value (canonical line: \`${section.example}\`).`
3681
+ });
3682
+ continue;
3683
+ }
3684
+ const value = doctorChecks.parseContextLine(content, section.re);
3685
+ if (value === null || (section.values && !section.values.has(value))) {
3686
+ findings.push({
3687
+ level: 'warn',
3688
+ title: `_conventions.md ${section.heading} line is not machine-readable`,
3689
+ detail: `Dflow parses a \`${section.example}\`-style line to infer project context; as written, inference returns null and ${section.nullEffect}.`,
3690
+ action: `Restore the canonical line format, e.g. \`${section.example}\`.`
3691
+ });
3692
+ }
3693
+ }
3694
+ }
3695
+
3696
+ // PROPOSAL-058 direction 2 (a): guide manageability + canonical staleness. The
3697
+ // canonical region is substitution-free by design (a test guards that), so a
3698
+ // current projection equals the packaged region byte-for-byte after LF
3699
+ // normalization.
3700
+ async function checkGuideCanonicalState(cwd, findings) {
3701
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
3702
+ if (!(await pathExists(guidePath))) {
3703
+ if (await pathExists(path.join(cwd, WORKFLOW_BUNDLE_DEST))) {
3704
+ findings.push({
3705
+ level: 'warn',
3706
+ title: `${AI_AGENT_GUIDE_DEST} is missing but the workflow bundle is projected`,
3707
+ detail: 'Bundle flow files § reference the guide; without it agents lose routing, ceremony, and transparency rules.',
3708
+ action: 'Run `dflow configure-agents` to project the guide.'
3709
+ });
3710
+ }
3711
+ return;
3712
+ }
3713
+ const content = (await fs.readFile(guidePath, 'utf8').catch(() => '')).replace(/\r\n/g, '\n');
3714
+ const region = classifyMarkedRegion(content, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
3715
+ if (region.state === 'malformed') {
3716
+ findings.push({
3717
+ level: 'warn',
3718
+ title: `${AI_AGENT_GUIDE_DEST} has malformed guide-canonical markers`,
3719
+ detail: '`dflow configure-agents` cannot locate the canonical region and will not refresh it.',
3720
+ action: 'Repair or remove the stray `<!-- dflow-generated: guide-canonical ... -->` markers, then re-run `dflow configure-agents`.'
3721
+ });
3722
+ return;
3723
+ }
3724
+ if (region.state === 'absent') {
3725
+ // Mirror the configure-agents bootstrap split: the adoption offer only
3726
+ // exists for a recognizable Dflow guide, so pointing an unrecognizable file
3727
+ // at the offer would be impossible advice.
3728
+ if (isRecognizableDflowGuide(content)) {
3729
+ findings.push({
3730
+ level: 'info',
3731
+ title: `${AI_AGENT_GUIDE_DEST} predates managed guide-canonical markers`,
3732
+ detail: 'Its canonical sections stay at the Dflow version that wrote them; upgrades cannot refresh them in place.',
3733
+ 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`.'
3734
+ });
3735
+ } else {
3736
+ findings.push({
3737
+ level: 'info',
3738
+ title: `${AI_AGENT_GUIDE_DEST} is not recognizable as a Dflow guide`,
3739
+ 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.',
3740
+ 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.'
3741
+ });
3742
+ }
3743
+ return;
3744
+ }
3745
+ const edition = await inferProjectBundleEdition(cwd);
3746
+ if (!edition) return;
3747
+ let packaged;
3748
+ try {
3749
+ packaged = await readPackagedTemplate(edition, 'scaffolding/AI-AGENT-GUIDE.md');
3750
+ } catch {
3751
+ return;
3752
+ }
3753
+ const packagedRegion = classifyMarkedRegion(packaged, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
3754
+ if (packagedRegion.state !== 'present') return;
3755
+ if (content.slice(region.startIdx, region.endIdx) !== packaged.slice(packagedRegion.startIdx, packagedRegion.endIdx)) {
3756
+ findings.push({
3757
+ level: 'info',
3758
+ title: `${AI_AGENT_GUIDE_DEST} canonical content differs from this CLI version`,
3759
+ detail: 'The marker-guarded canonical region does not match what this Dflow version projects.',
3760
+ action: 'Run `dflow configure-agents` to refresh the canonical region in place (content outside the markers is kept).'
3761
+ });
3762
+ }
3763
+ }
3764
+
3765
+ // PROPOSAL-076: the guide's "## Project Context" rows are the machine-readable
3766
+ // source context inference reads (tech stack / migration context). Unlike the
3767
+ // _overview.md rows the pre-076 inference looked for — which never existed in
3768
+ // any packaged template, so their absence is canonical — these rows have
3769
+ // shipped in every projected guide, so a missing or unparseable row here IS
3770
+ // drift worth reporting. Info-level only: the inference fallback
3771
+ // ('unknown'/'none') is benign, and rewriting Project Context is the user's
3772
+ // designed freedom (PROPOSAL-058 boundary).
3773
+ async function checkGuideProjectContextFormat(cwd, findings) {
3774
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
3775
+ if (!(await pathExists(guidePath))) return; // missing guide is checkGuideCanonicalState's finding
3776
+ const content = (await fs.readFile(guidePath, 'utf8').catch(() => '')).replace(/\r\n/g, '\n');
3777
+ // Judge marker-managed guides and recognizable pre-marker guides. Anything
3778
+ // else already gets its own unrecognizable / malformed finding, where
3779
+ // row-level advice would be impossible advice. Deliberately NOT only the
3780
+ // adoption predicate: a marker-managed guide whose "## Project Context" was
3781
+ // deleted has fine markers and fails recognizability, yet inference just
3782
+ // lost its source — that is a finding, not a skip (gate G5).
3783
+ const markers = classifyMarkedRegion(content, GUIDE_CANONICAL_SECTION_START, GUIDE_CANONICAL_SECTION_END);
3784
+ if (markers.state !== 'present' && !isRecognizableDflowGuide(content)) return;
3785
+ const section = projectContextParseSlice(content);
3786
+ if (section === null) {
3787
+ findings.push({
3788
+ level: 'info',
3789
+ title: `${AI_AGENT_GUIDE_DEST} has no "## Project Context" section`,
3790
+ 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.',
3791
+ action: 'Restore a "## Project Context" section above the guide-canonical markers, including the `| Tech stack | ... |` and `| Migration / legacy context | ... |` rows.'
3792
+ });
3793
+ return;
3794
+ }
3795
+ const rows = [
3796
+ { label: 'Tech stack', re: doctorChecks.TECH_STACK_ROW_RE, example: '| Tech stack | ASP.NET Core 9, EF Core, xUnit |', fallback: '`unknown`' },
3797
+ { label: 'Migration / legacy context', re: doctorChecks.MIGRATION_CONTEXT_ROW_RE, example: '| Migration / legacy context | none |', fallback: '`none`' }
3798
+ ];
3799
+ const missing = rows.filter((row) => doctorChecks.parseContextLine(section, row.re) === null);
3800
+ if (missing.length === 0) return;
3801
+ findings.push({
3802
+ level: 'info',
3803
+ title: `${AI_AGENT_GUIDE_DEST} "## Project Context" is missing machine-readable row(s): ${missing.map((row) => row.label).join(', ')}`,
3804
+ 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.`,
3805
+ action: `Restore the table row format inside "## Project Context", e.g. \`${missing[0].example}\`.`
3806
+ });
3807
+ }
3808
+
3809
+ // PROPOSAL-058 direction 2 (a): dangling `AI-AGENT-GUIDE.md § Heading`
3810
+ // references — the drift class that motivated this proposal: a frozen guide plus
3811
+ // a refreshed workflow bundle leaves flow files pointing at guide sections that
3812
+ // do not exist.
3813
+ async function checkGuideSectionRefs(cwd, findings) {
3814
+ const guidePath = path.join(cwd, AI_AGENT_GUIDE_DEST);
3815
+ if (!(await pathExists(guidePath))) return; // missing guide reported above
3816
+ const guideHeadings = doctorChecks.extractHeadings(await fs.readFile(guidePath, 'utf8').catch(() => ''));
3817
+ if (guideHeadings.length === 0) return;
3818
+
3819
+ const scanFiles = [{ rel: 'dflow/specs/shared/_conventions.md', abs: path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md') }];
3820
+ for (const dir of ['references', 'templates']) {
3821
+ const absoluteDir = path.join(cwd, WORKFLOW_BUNDLE_DEST, dir);
3822
+ let entries = [];
3823
+ try {
3824
+ entries = await fs.readdir(absoluteDir);
3825
+ } catch {
3826
+ continue;
3827
+ }
3828
+ for (const entry of entries) {
3829
+ if (entry.endsWith('.md')) {
3830
+ scanFiles.push({ rel: `${WORKFLOW_BUNDLE_DEST}/${dir}/${entry}`, abs: path.join(absoluteDir, entry) });
3831
+ }
3832
+ }
3833
+ }
3834
+
3835
+ const dangling = [];
3836
+ for (const file of scanFiles) {
3837
+ const content = await fs.readFile(file.abs, 'utf8').catch(() => null);
3838
+ if (content === null) continue;
3839
+ for (const ref of doctorChecks.extractSectionRefs(content, 'AI-AGENT-GUIDE.md')) {
3840
+ if (!doctorChecks.headingResolves(ref.headingText, guideHeadings)) {
3841
+ dangling.push(`${file.rel}:${ref.line} § "${ref.headingText}"`);
3842
+ }
3843
+ }
3844
+ }
3845
+ if (dangling.length === 0) return;
3846
+ const shown = dangling.slice(0, 8);
3847
+ const more = dangling.length - shown.length;
3848
+ findings.push({
3849
+ level: 'warn',
3850
+ title: `Dangling AI-AGENT-GUIDE.md § reference(s): ${dangling.length}`,
3851
+ 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.`,
3852
+ action: 'Refresh the guide canonical content with `dflow configure-agents` (accept marker adoption if offered), or align the guide manually.'
3853
+ });
3854
+ }
3855
+
3856
+ // PROPOSAL-058 direction 2 (d), user decision 2026-06-08 OQ5: init-only starters
3857
+ // are user-owned and never re-projected — doctor only reports, never rewrites.
3858
+ async function checkInitOnlyStarters(cwd, findings) {
3859
+ const conventions = await fs.readFile(path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md'), 'utf8').catch(() => '');
3860
+ const parsedPolicy = doctorChecks.parseContextLine(conventions, doctorChecks.GIT_POLICY_LINE_RE);
3861
+ const policy = parsedPolicy && doctorChecks.GIT_POLICY_VALUES.has(parsedPolicy) ? parsedPolicy : null;
3862
+ const edition = await inferProjectBundleEdition(cwd);
3863
+
3864
+ if (policy) {
3865
+ const relativePath = `dflow/specs/shared/Git-principles-${policy}.md`;
3866
+ const absolute = path.join(cwd, relativePath);
3867
+ if (!(await pathExists(absolute))) {
3868
+ findings.push({
3869
+ level: 'warn',
3870
+ title: `${relativePath} is missing`,
3871
+ detail: `The selected Git policy (\`${policy}\`) names this principles file; runtime branch gates and finish-feature guidance read it.`,
3872
+ action: 'Recover it from a fresh `dflow init` in a scratch directory (init-only starter; `dflow configure-agents` does not re-project it).'
3873
+ });
3874
+ } else if (edition) {
3875
+ let template = null;
3876
+ try {
3877
+ template = await readPackagedTemplate(edition, `scaffolding/Git-principles-${policy}.md`);
3878
+ } catch {
3879
+ template = null;
3880
+ }
3881
+ if (template) {
3882
+ const projected = await fs.readFile(absolute, 'utf8').catch(() => '');
3883
+ if (!doctorChecks.matchesTemplateWithPlaceholders(projected, template)) {
3884
+ findings.push({
3885
+ level: 'info',
3886
+ title: `${relativePath} differs from the current packaged starter`,
3887
+ detail: 'It is user-owned, so this may be your own edits — or an older Dflow starter shape.',
3888
+ action: 'If you never customized it, compare against a fresh `dflow init` and update manually; Dflow never rewrites it.'
3889
+ });
3890
+ }
3891
+ }
3892
+ }
3893
+ }
3894
+
3895
+ // No _overview.md machine-format check: no packaged _overview template has
3896
+ // ever carried `| Tech stack |` / `| Migration / legacy context |` rows, so
3897
+ // their absence there is the canonical state, not drift. The machine-readable
3898
+ // home of those two context values is the guide's "## Project Context" table
3899
+ // — inference reads it and checkGuideProjectContextFormat reports drift
3900
+ // (PROPOSAL-076).
3901
+ }
3902
+
3903
+ // PROPOSAL-058 direction 2 (e): template-shape drift for filled feature
3904
+ // dashboards. Detection + pointers only — migrating a filled document needs
3905
+ // judgment (old content into new sections), so the migration itself is
3906
+ // AI-assisted, and completed/ features are deliberately not scanned (they keep
3907
+ // their historical shape — BUG-001 decision).
3908
+ async function checkFeatureIndexShape(cwd, findings) {
3909
+ const edition = await inferProjectBundleEdition(cwd);
3910
+ if (!edition) return;
3911
+ let template;
3912
+ try {
3913
+ template = await readPackagedTemplate(edition, 'templates/_index.md');
3914
+ } catch {
3915
+ return;
3916
+ }
3917
+ const activeDir = path.join(cwd, 'dflow', 'specs', 'features', 'active');
3918
+ let entries = [];
3919
+ try {
3920
+ entries = await fs.readdir(activeDir, { withFileTypes: true });
3921
+ } catch {
3922
+ return;
3923
+ }
3924
+ for (const entry of entries) {
3925
+ if (!entry.isDirectory()) continue;
3926
+ const relativePath = `dflow/specs/features/active/${entry.name}/_index.md`;
3927
+ const content = await fs.readFile(path.join(activeDir, entry.name, '_index.md'), 'utf8').catch(() => null);
3928
+ if (content === null) continue;
3929
+ const missing = doctorChecks.missingTemplateSections(template, content);
3930
+ if (missing.length === 0) continue;
3931
+ findings.push({
3932
+ level: 'info',
3933
+ title: `${relativePath} looks like an older _index.md template shape`,
3934
+ detail: `Missing section(s) vs the current template: ${missing.join(', ')}. Ignore this if you removed them on purpose.`,
3935
+ 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.'
3936
+ });
3937
+ }
3938
+ }
3939
+
3940
+ // PROPOSAL-058: root agent shim manageability. Only existing files are
3941
+ // classified — which agents a project uses is not recorded, so an absent file is
3942
+ // not a finding; a file with no Dflow content at all is the user's own business.
3943
+ async function checkRootAgentShims(cwd, findings) {
3944
+ for (const agent of ['agents', 'claude', 'copilot']) {
3945
+ const target = getAiAgentTarget(agent);
3946
+ const raw = await fs.readFile(path.join(cwd, target.relativePath), 'utf8').catch(() => null);
3947
+ if (raw === null) continue;
3948
+ const lf = raw.replace(/\r\n/g, '\n');
3949
+ // AGENTS.md can carry a second managed pair (the Codex command-trigger
3950
+ // block); a malformed trigger pair blocks `--command-adapters` trigger
3951
+ // management (snippet fallback) even when the agent-shim block is healthy.
3952
+ if (agent === 'agents') {
3953
+ const triggerRegion = classifyMarkedRegion(lf, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
3954
+ if (triggerRegion.state === 'malformed') {
3955
+ findings.push({
3956
+ level: 'warn',
3957
+ title: `${target.relativePath} has malformed Dflow command-trigger markers`,
3958
+ detail: '`dflow configure-agents --command-adapters` cannot manage the trigger block and falls back to a merge snippet.',
3959
+ action: 'Remove the stray `<!-- dflow-generated: codex-command-triggers ... -->` markers, then re-run `dflow configure-agents --command-adapters`.'
3960
+ });
3961
+ }
3962
+ }
3963
+ const region = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
3964
+ if (region.state === 'malformed') {
3965
+ findings.push({
3966
+ level: 'warn',
3967
+ title: `${target.relativePath} has malformed Dflow markers`,
3968
+ detail: '`dflow configure-agents` cannot manage the Dflow block and falls back to a merge snippet.',
3969
+ action: 'Remove the stray Dflow markers, then re-run `dflow configure-agents`.'
3970
+ });
3971
+ continue;
3972
+ }
3973
+ if (region.state === 'present') continue; // marker-managed: refreshed in place
3974
+ const baseShim = buildAiAgentShim(target.relativePath);
3975
+ if (isPristineDflowAgentsShim(raw, baseShim, target.relativePath)) continue; // regenerated in place
3976
+ if (contentReferencesAiAgentGuide(raw)) {
3977
+ findings.push({
3978
+ level: 'info',
3979
+ title: `${target.relativePath} references the Dflow guide but is not Dflow-managed`,
3980
+ detail: 'Dflow wording inside it stays frozen on upgrade (no markers, and not a pristine Dflow shim).',
3981
+ 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.'
3982
+ });
3983
+ }
3984
+ }
3985
+ }
3986
+
3987
+ // PROPOSAL-058 direction 2 (c) companion: the bundle manifest records which
3988
+ // Dflow version last projected the workflow bundle.
3989
+ async function checkBundleManifestVersion(cwd, findings) {
3990
+ const manifestResult = await readCurrentBundleManifest(cwd);
3991
+ if (manifestResult.kind !== 'ok') return;
3992
+ const version = manifestResult.manifest.version;
3993
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(version) || version === pkg.version) return;
3994
+ if (compareVersions(version, pkg.version) < 0) {
3995
+ findings.push({
3996
+ level: 'info',
3997
+ title: `Workflow bundle was projected by Dflow ${version}; this CLI is ${pkg.version}`,
3998
+ detail: 'The references/ and templates/ files under dflow-workflows/ are from the older version.',
3999
+ action: 'Run `dflow configure-agents` to re-project the bundle.'
4000
+ });
4001
+ }
4002
+ }
4003
+
3237
4004
  // PROPOSAL-052 (c): read-only mop-up for the manifest-orphan edge. A
3238
4005
  // Dflow-generated bundle file that is no longer in the current package source
3239
4006
  // can linger if it was retired before generalized stale-removal shipped, or the
@@ -3333,5 +4100,11 @@ module.exports = {
3333
4100
  // synthetic descriptor lists without touching the packaged templates/ tree.
3334
4101
  assertNoBundleCollision,
3335
4102
  assertEditionBundleComplete,
3336
- assertCommonBundleComplete
4103
+ assertCommonBundleComplete,
4104
+ // Exported for tests (PROPOSAL-076): context inference reads the guide's
4105
+ // Project Context rows, but its only write consumer is whole-guide creation
4106
+ // when the guide is missing — the real-value read has no black-box write to
4107
+ // observe, so tests call these directly.
4108
+ inferTechStackSummary,
4109
+ inferMigrationContext
3337
4110
  };