dflow-sdd-ddd 0.12.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/CONTRIBUTING.md +6 -9
  3. package/README.en.md +117 -40
  4. package/README.md +47 -15
  5. package/TEMPLATE-COVERAGE.md +3 -2
  6. package/bin/dflow.js +80 -3
  7. package/docs/evaluating-dflow.en.md +21 -2
  8. package/docs/evaluating-dflow.md +17 -3
  9. package/docs/npm-publish-checklist.md +3 -1
  10. package/docs/release-versioning-policy.md +3 -2
  11. package/docs/using-with-claude-code.en.md +35 -22
  12. package/docs/using-with-claude-code.md +27 -17
  13. package/docs/using-with-codex.en.md +20 -10
  14. package/docs/using-with-codex.md +13 -8
  15. package/docs/using-with-github-copilot.en.md +20 -9
  16. package/docs/using-with-github-copilot.md +14 -7
  17. package/lib/doctor-checks.js +178 -0
  18. package/lib/init.js +894 -36
  19. package/lib/render.js +1263 -0
  20. package/package.json +5 -2
  21. package/templates/brownfield/references/init-project-flow.md +46 -2
  22. package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +13 -1
  23. package/templates/brownfield/templates/_index.md +2 -0
  24. package/templates/brownfield/templates/context-definition.md +2 -0
  25. package/templates/brownfield/templates/context-map.md +1 -0
  26. package/templates/brownfield/templates/glossary.md +1 -0
  27. package/templates/brownfield/templates/models.md +1 -0
  28. package/templates/brownfield/templates/phase-spec.md +2 -0
  29. package/templates/brownfield/templates/rules.md +1 -0
  30. package/templates/brownfield/templates/tech-debt.md +1 -0
  31. package/templates/greenfield/references/init-project-flow.md +48 -6
  32. package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +13 -1
  33. package/templates/greenfield/templates/_index.md +2 -0
  34. package/templates/greenfield/templates/aggregate-design.md +2 -0
  35. package/templates/greenfield/templates/context-definition.md +2 -0
  36. package/templates/greenfield/templates/context-map.md +1 -0
  37. package/templates/greenfield/templates/events.md +1 -0
  38. package/templates/greenfield/templates/glossary.md +1 -0
  39. package/templates/greenfield/templates/models.md +1 -0
  40. package/templates/greenfield/templates/phase-spec.md +2 -0
  41. package/templates/greenfield/templates/rules.md +1 -0
  42. package/templates/greenfield/templates/tech-debt.md +1 -0
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';
@@ -250,6 +258,14 @@ async function runInit(options = {}) {
250
258
 
251
259
  const detection = await detectProjectSignals(cwd);
252
260
  const answers = await promptForAnswers(rl, stdout, stderr, detection);
261
+ // PROPOSAL-074: skill question sits after the AI-agents question and before the
262
+ // preview; asked only when a skill-capable agent was selected, and only on TTY.
263
+ answers.skills = await resolveSkillInstall(
264
+ rl,
265
+ stdout,
266
+ Boolean(stdin.isTTY && stdout.isTTY),
267
+ answers.aiAgents.some((agent) => SKILL_ADAPTER_TARGETS[agent])
268
+ );
253
269
  const plan = await buildFilePlan(cwd, answers);
254
270
  const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.warnings || []), ...(plan.bundleWarnings || [])];
255
271
 
@@ -268,7 +284,7 @@ async function runInit(options = {}) {
268
284
  result.warnings.push(...collectUnresolvedPlaceholderWarnings(plan, result.created));
269
285
 
270
286
  printResultReport(stdout, result, plan.deferred);
271
- printNextSteps(stdout);
287
+ printNextSteps(stdout, answers.skills);
272
288
  return 0;
273
289
  } catch (error) {
274
290
  if (rl) {
@@ -331,13 +347,75 @@ async function runConfigureAgents(options = {}) {
331
347
  throw new UserAbort('No AI agents selected. Nothing changed.');
332
348
  }
333
349
 
334
- const plan = await buildConfigureAgentsPlan(cwd, {
350
+ // PROPOSAL-074 (OQ2 branch b): without --skills, a selected agent that has no
351
+ // project-level skill yet gets the same default-yes install contract as init
352
+ // (TTY asks, non-TTY installs without reading stdin). Agents whose skill file
353
+ // already exists — Dflow-generated or user-owned — never re-ask and are NOT
354
+ // regenerated (skillAgents carries only the missing ones); explicit --skills
355
+ // keeps its original regenerate-all meaning.
356
+ let skillAgents = [];
357
+ if (options.skills) {
358
+ skillAgents = aiAgents;
359
+ } else {
360
+ const missingSkillAgents = [];
361
+ for (const agent of aiAgents) {
362
+ const target = SKILL_ADAPTER_TARGETS[agent];
363
+ if (target && !(await pathExists(path.join(cwd, target.relativePath)))) {
364
+ missingSkillAgents.push(agent);
365
+ }
366
+ }
367
+ const install = await resolveSkillInstall(
368
+ rl,
369
+ stdout,
370
+ Boolean(stdin.isTTY && stdout.isTTY),
371
+ missingSkillAgents.length > 0
372
+ );
373
+ if (install) {
374
+ skillAgents = missingSkillAgents;
375
+ }
376
+ }
377
+
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, {
335
390
  ...projectContext,
336
391
  aiAgents,
337
392
  commandAdapters: Boolean(options.commandAdapters),
338
- skills: Boolean(options.skills)
393
+ skills: Boolean(options.skills),
394
+ skillAgents,
395
+ adoptGuideMarkers,
396
+ adoptShimAgents
339
397
  });
340
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
+
341
419
  const warnings = plan.warnings || [];
342
420
  renderPreview(stdout, plan, warnings);
343
421
  const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
@@ -355,7 +433,7 @@ async function runConfigureAgents(options = {}) {
355
433
 
356
434
  printResultReport(stdout, result, plan.deferred);
357
435
  const usedSnippetFallback = plan.items.some((item) => item.snippetFallback);
358
- printConfigureAgentsNextSteps(stdout, Boolean(options.commandAdapters), usedSnippetFallback);
436
+ printConfigureAgentsNextSteps(stdout, Boolean(options.commandAdapters), usedSnippetFallback, skillAgents.length > 0);
359
437
  return 0;
360
438
  } catch (error) {
361
439
  if (rl) {
@@ -715,18 +793,19 @@ async function inferProjectContext(cwd, rl, stdout, stderr) {
715
793
  };
716
794
  }
717
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).
718
799
  async function inferGitPolicy(cwd) {
719
800
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
720
801
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
721
- const match = content.match(/Selected Git policy:\s*`([^`]+)`/);
722
- return match ? match[1] : null;
802
+ return doctorChecks.parseContextLine(content, doctorChecks.GIT_POLICY_LINE_RE);
723
803
  }
724
804
 
725
805
  async function inferAiCommitMarker(cwd) {
726
806
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
727
807
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
728
- const match = content.match(/AI commit marker:\s*`([^`]+)`/);
729
- return match ? match[1] : null;
808
+ return doctorChecks.parseContextLine(content, doctorChecks.AI_COMMIT_MARKER_LINE_RE);
730
809
  }
731
810
 
732
811
  async function inferExistingEdition(cwd) {
@@ -745,22 +824,43 @@ async function inferExistingEdition(cwd) {
745
824
  async function inferProseLanguage(cwd) {
746
825
  const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
747
826
  const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
748
- const match = content.match(/Project prose language:\s*`([^`]+)`/);
749
- return match ? match[1] : 'unknown';
827
+ return doctorChecks.parseContextLine(content, doctorChecks.PROSE_LANGUAGE_LINE_RE) ?? 'unknown';
750
828
  }
751
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.
752
838
  async function inferTechStackSummary(cwd) {
753
- const overviewPath = path.join(cwd, 'dflow/specs/shared/_overview.md');
754
- const content = await fs.readFile(overviewPath, 'utf8').catch(() => '');
755
- const match = content.match(/\|\s*Tech stack\s*\|\s*([^|\n]+?)\s*\|/i);
756
- return match ? match[1].trim() : 'unknown';
839
+ return (await inferGuideProjectContextValue(cwd, doctorChecks.TECH_STACK_ROW_RE)) ?? 'unknown';
757
840
  }
758
841
 
759
842
  async function inferMigrationContext(cwd) {
760
- const overviewPath = path.join(cwd, 'dflow/specs/shared/_overview.md');
761
- const content = await fs.readFile(overviewPath, 'utf8').catch(() => '');
762
- const match = content.match(/\|\s*Migration \/ legacy context\s*\|\s*([^|\n]+?)\s*\|/i);
763
- 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');
764
864
  }
765
865
 
766
866
  async function askSelect(rl, stdout, stderr, config) {
@@ -978,6 +1078,38 @@ async function askConfirmation(rl, prompt) {
978
1078
  return answer === 'y' || answer === 'yes';
979
1079
  }
980
1080
 
1081
+ // PROPOSAL-074: dedicated default-yes contract for the skill-install question.
1082
+ // askConfirmation treats blank as false (final-confirmation semantics), so reusing
1083
+ // it under a `(Y/n)` prompt would invert the advertised default.
1084
+ async function askYesNoDefaultYes(rl, prompt) {
1085
+ const answer = (await askLine(rl, prompt)).trim().toLowerCase();
1086
+ return answer === '' || answer === 'y' || answer === 'yes';
1087
+ }
1088
+
1089
+ // PROPOSAL-074: the project-level skill installs by default. Ask only on an
1090
+ // interactive terminal; a non-TTY run never consumes a stdin slot — existing piped
1091
+ // answer sequences end with the final confirmation `y`, and a new question before
1092
+ // it would swallow that `y` and turn the run into a silent no-op abort — so
1093
+ // non-TTY installs by default without reading stdin.
1094
+ async function resolveSkillInstall(rl, stdout, interactive, hasSkillTargets) {
1095
+ if (!hasSkillTargets) {
1096
+ return false;
1097
+ }
1098
+
1099
+ if (!interactive) {
1100
+ return true;
1101
+ }
1102
+
1103
+ const install = await askYesNoDefaultYes(
1104
+ rl,
1105
+ '\nInstall the project-level Dflow skill for natural-language auto-trigger? (Y/n) '
1106
+ );
1107
+ if (!install) {
1108
+ stdout.write('Skipped the project-level skill; add it later with `dflow configure-agents --skills`.\n');
1109
+ }
1110
+ return install;
1111
+ }
1112
+
981
1113
  function parseSelectAnswer(answer, options, defaultKey) {
982
1114
  const trimmed = answer.trim();
983
1115
  if (!trimmed && defaultKey) {
@@ -1124,6 +1256,11 @@ async function buildFilePlan(cwd, answers) {
1124
1256
 
1125
1257
  await finalizePlanItems(cwd, items);
1126
1258
 
1259
+ // PROPOSAL-074: init projects the project-level skill by default; answers.skills
1260
+ // carries the Q-flow / non-TTY resolution from runInit (absent = false, which
1261
+ // keeps buildFilePlan backward-compatible for direct callers).
1262
+ await addSkillAdapterItems(cwd, items, answers.aiAgents, answers.skills, warnings);
1263
+
1127
1264
  // Always project the workflow bundle (required for /dflow:* workflows to be reachable).
1128
1265
  const bundleWarnings = [];
1129
1266
  await addWorkflowBundleItems(cwd, items, bundleWarnings, answers.edition);
@@ -1147,19 +1284,18 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1147
1284
  const items = [];
1148
1285
  const warnings = [];
1149
1286
 
1150
- let content = await readPackagedTemplate(answers.edition, 'scaffolding/AI-AGENT-GUIDE.md');
1151
- content = substitutePlaceholders(content, substitution);
1152
- items.push({
1153
- relativePath: 'dflow/specs/shared/AI-AGENT-GUIDE.md',
1154
- source: `packaged:${answers.edition}/scaffolding/AI-AGENT-GUIDE.md`,
1155
- notes: 'canonical AI agent guide',
1156
- content
1157
- });
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);
1158
1290
 
1159
- const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(content) : [];
1291
+ const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(packagedGuide) : [];
1160
1292
 
1161
1293
  for (const agent of answers.aiAgents) {
1162
- await addAiAgentShim(cwd, items, agent, substitution, { commandRegistry, warnings });
1294
+ await addAiAgentShim(cwd, items, agent, substitution, {
1295
+ commandRegistry,
1296
+ warnings,
1297
+ adoptShimAgents: answers.adoptShimAgents || []
1298
+ });
1163
1299
  }
1164
1300
 
1165
1301
  if (answers.commandAdapters) {
@@ -1172,7 +1308,11 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1172
1308
  await addLegacyCommandAdapterCleanupItems(cwd, items, answers.aiAgents, warnings);
1173
1309
  }
1174
1310
 
1175
- await addSkillAdapterItems(cwd, items, answers.aiAgents, answers.skills, warnings);
1311
+ // PROPOSAL-074: skillAgents is the projection subset — all selected agents under
1312
+ // --skills, only the missing ones on a flagless default install (existing skills
1313
+ // are never regenerated without the flag).
1314
+ const skillAgents = answers.skillAgents || (answers.skills ? answers.aiAgents : []);
1315
+ await addSkillAdapterItems(cwd, items, skillAgents, skillAgents.length > 0, warnings);
1176
1316
 
1177
1317
  // Project the workflow bundle on configure-agents too, so pre-039 projects (no bundle)
1178
1318
  // and edition-switch repairs get the runtime references/templates reachable. The function
@@ -1182,6 +1322,15 @@ async function buildConfigureAgentsPlan(cwd, answers) {
1182
1322
  await addWorkflowBundleItems(cwd, items, bundleWarnings, answers.edition);
1183
1323
  warnings.push(...bundleWarnings);
1184
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
+
1185
1334
  return {
1186
1335
  items,
1187
1336
  deferred: [],
@@ -1545,6 +1694,219 @@ async function readPackagedBundleFile(sourceRoot, sourceRel) {
1545
1694
  }
1546
1695
  }
1547
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
+
1548
1910
  // PROPOSAL-054: configure a tool's root agent file. A non-guide existing file used
1549
1911
  // to be parked as a side merge snippet ("hand-merge this yourself"); it is now an
1550
1912
  // auto-injected, marker-delimited Dflow block shown in the confirmation preview.
@@ -1701,7 +2063,40 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1701
2063
  // marker-managed (a guide-configured file the user wrote / heavily edited). Keep
1702
2064
  // the base shim skipped so we never duplicate their guide pointer. Under Codex
1703
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.
1704
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
+ }
1705
2100
  if (wantsTrigger) {
1706
2101
  pushRootInjectItem(items, {
1707
2102
  relativePath: target.relativePath,
@@ -1710,6 +2105,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1710
2105
  content: applyEol(upsertCodexTriggerBlock(lf, triggerBlock), eol),
1711
2106
  expectedContent: existingContent
1712
2107
  });
2108
+ items[items.length - 1].offerShimAdoption = agent;
1713
2109
  } else {
1714
2110
  items.push({
1715
2111
  relativePath: target.relativePath,
@@ -1718,6 +2114,7 @@ async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1718
2114
  content: fullShim,
1719
2115
  action: 'skip',
1720
2116
  intentionalSkip: true,
2117
+ offerShimAdoption: agent,
1721
2118
  size: Buffer.byteLength(fullShim, 'utf8')
1722
2119
  });
1723
2120
  }
@@ -2439,8 +2836,13 @@ function buildSubstitutionMap(cwd, answers) {
2439
2836
  ['{系統名稱}', systemName],
2440
2837
  ['{project-type}', answers.projectType],
2441
2838
  ['{edition}', answers.edition],
2442
- ['{tech-stack-summary}', answers.techStackSummary],
2443
- ['{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)],
2444
2846
  ['{prose-language}', answers.proseLanguage],
2445
2847
  ['{dflow-version}', pkg.version],
2446
2848
  ['{Language}', extracted.language || '{Language}'],
@@ -2783,10 +3185,28 @@ async function writeFilePlan(cwd, plan) {
2783
3185
  warnings: []
2784
3186
  };
2785
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
+
2786
3198
  for (const item of plan.items) {
2787
3199
  const targetPath = path.join(cwd, item.relativePath);
2788
3200
 
2789
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
+
2790
3210
  if (item.action === 'remove') {
2791
3211
  let stats;
2792
3212
  try {
@@ -2801,6 +3221,7 @@ async function writeFilePlan(cwd, plan) {
2801
3221
  }
2802
3222
 
2803
3223
  if (!stats.isFile()) {
3224
+ unexpectedSkip = true;
2804
3225
  result.skipped.push(item.relativePath);
2805
3226
  result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
2806
3227
  continue;
@@ -2808,6 +3229,7 @@ async function writeFilePlan(cwd, plan) {
2808
3229
 
2809
3230
  const currentContent = await fs.readFile(targetPath, 'utf8');
2810
3231
  if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
3232
+ unexpectedSkip = true;
2811
3233
  result.skipped.push(item.relativePath);
2812
3234
  result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
2813
3235
  continue;
@@ -2830,6 +3252,7 @@ async function writeFilePlan(cwd, plan) {
2830
3252
  stats = await fs.stat(targetPath);
2831
3253
  } catch (error) {
2832
3254
  if (error.code === 'ENOENT') {
3255
+ unexpectedSkip = true;
2833
3256
  result.skipped.push(item.relativePath);
2834
3257
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} no longer exists; re-run to inject the Dflow block.`);
2835
3258
  continue;
@@ -2837,12 +3260,14 @@ async function writeFilePlan(cwd, plan) {
2837
3260
  throw error;
2838
3261
  }
2839
3262
  if (!stats.isFile()) {
3263
+ unexpectedSkip = true;
2840
3264
  result.skipped.push(item.relativePath);
2841
3265
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} is no longer a regular file; re-run to inject the Dflow block.`);
2842
3266
  continue;
2843
3267
  }
2844
3268
  const currentRaw = await fs.readFile(targetPath, 'utf8');
2845
3269
  if (currentRaw !== item.expectedContent) {
3270
+ unexpectedSkip = true;
2846
3271
  result.skipped.push(item.relativePath);
2847
3272
  result.warnings.push(`Skipped Dflow block update because ${item.relativePath} changed after the preview; re-run to inject the Dflow block.`);
2848
3273
  continue;
@@ -2870,6 +3295,7 @@ async function writeFilePlan(cwd, plan) {
2870
3295
  // already-current Dflow block) is expected, not a problem — don't emit the
2871
3296
  // generic "skipped existing target" warning for it.
2872
3297
  if (!item.intentionalSkip) {
3298
+ unexpectedSkip = true;
2873
3299
  result.warnings.push(`Skipped existing target: ${item.relativePath}`);
2874
3300
  }
2875
3301
  if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
@@ -2911,6 +3337,10 @@ async function writeFilePlan(cwd, plan) {
2911
3337
  }
2912
3338
 
2913
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;
2914
3344
  result.skipped.push(item.relativePath);
2915
3345
  result.warnings.push(`Skipped existing target: ${item.relativePath}`);
2916
3346
  continue;
@@ -2991,7 +3421,12 @@ function printResultReport(stdout, result, deferred) {
2991
3421
  }
2992
3422
  }
2993
3423
 
2994
- function printNextSteps(stdout) {
3424
+ // PROPOSAL-074 / PROPOSAL-037: generated skill files are Dflow-managed derivatives;
3425
+ // the recommended default is gitignore + re-project after clone.
3426
+ const SKILL_VERSION_CONTROL_STEP = '- Project-level skill files (.claude/skills/, .agents/skills/, .github/skills/) are Dflow-managed derivatives: the recommended default is to gitignore them and re-run `dflow configure-agents --skills` after cloning; committing them also works if the team prefers.\n';
3427
+
3428
+ function printNextSteps(stdout, skillsInstalled = false) {
3429
+ const skillStep = skillsInstalled ? SKILL_VERSION_CONTROL_STEP : '';
2995
3430
  stdout.write(`
2996
3431
  Dflow init complete.
2997
3432
 
@@ -3000,10 +3435,10 @@ Recommended next steps:
3000
3435
  - For brownfield changes, use the Dflow modify-existing workflow when it becomes available as a CLI command.
3001
3436
  - Before generating more specs, make sure dflow/specs/shared/_conventions.md has the correct Prose Language section.
3002
3437
  - For stack-specific examples (.NET, Java/Spring, Node/TypeScript, Python, Go, PHP/Laravel), see docs/examples-by-stack.md in the Dflow repo.
3003
- `);
3438
+ ${skillStep}`);
3004
3439
  }
3005
3440
 
3006
- function printConfigureAgentsNextSteps(stdout, commandAdapters = false, snippetFallback = false) {
3441
+ function printConfigureAgentsNextSteps(stdout, commandAdapters = false, snippetFallback = false, skillsInstalled = false) {
3007
3442
  const commandAdapterStep = commandAdapters
3008
3443
  ? '- Command adapters use tool-specific invocation names: Claude Code `/dflow:<id>`; GitHub Copilot prompt menu `/dflow-<id>` or canonical `/dflow:<id>` as text; Codex CLI plain text without a slash, such as `dflow:status`. Canonical `/dflow:*` names remain defined in dflow/specs/shared/AI-AGENT-GUIDE.md. If upgrading from Dflow 0.5.0, stale `.claude/commands/dflow/dflow-*.md` files generated by 0.5.0 are detected and listed for removal in the confirmation preview, so Claude Code does not show both old and new command names; edited or non-Dflow files are kept with a warning.\n'
3009
3444
  : '';
@@ -3015,13 +3450,15 @@ function printConfigureAgentsNextSteps(stdout, commandAdapters = false, snippetF
3015
3450
  ? '- A merge snippet was written because an existing agent file had conflicting Dflow markers; review it, fix or remove the stray markers, then merge the Dflow block into that file (or re-run to let Dflow manage it).\n'
3016
3451
  : '';
3017
3452
 
3453
+ const skillStep = skillsInstalled ? SKILL_VERSION_CONTROL_STEP : '';
3454
+
3018
3455
  stdout.write(`
3019
3456
  Dflow AI agent configuration complete.
3020
3457
 
3021
3458
  Recommended next steps:
3022
3459
  - Keep AI-agent-specific root files small.
3023
3460
  - Put durable workflow changes in dflow/specs/shared/AI-AGENT-GUIDE.md.
3024
- ${snippetStep}${commandAdapterStep}`);
3461
+ ${snippetStep}${skillStep}${commandAdapterStep}`);
3025
3462
  }
3026
3463
 
3027
3464
  function printList(stdout, values) {
@@ -3121,7 +3558,16 @@ async function runDoctor(options = {}) {
3121
3558
 
3122
3559
  const findings = [];
3123
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);
3124
3569
  await checkOrphanedWorkflowBundleFiles(cwd, findings);
3570
+ await checkBundleManifestVersion(cwd, findings);
3125
3571
 
3126
3572
  printDoctorReport(stdout, cwd, findings);
3127
3573
  return 0;
@@ -3149,6 +3595,412 @@ async function checkConventionsDflowVersion(cwd, findings) {
3149
3595
  }
3150
3596
  }
3151
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
+
3152
4004
  // PROPOSAL-052 (c): read-only mop-up for the manifest-orphan edge. A
3153
4005
  // Dflow-generated bundle file that is no longer in the current package source
3154
4006
  // can linger if it was retired before generalized stale-removal shipped, or the
@@ -3248,5 +4100,11 @@ module.exports = {
3248
4100
  // synthetic descriptor lists without touching the packaged templates/ tree.
3249
4101
  assertNoBundleCollision,
3250
4102
  assertEditionBundleComplete,
3251
- 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
3252
4110
  };