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/CHANGELOG.md +65 -1
- package/CONTRIBUTING.md +6 -9
- package/README.en.md +36 -25
- package/README.md +10 -8
- package/TEMPLATE-COVERAGE.md +2 -2
- package/bin/dflow.js +22 -1
- package/docs/npm-publish-checklist.md +3 -1
- package/docs/release-versioning-policy.md +3 -2
- package/docs/using-with-claude-code.en.md +12 -6
- package/docs/using-with-claude-code.md +7 -3
- package/docs/using-with-codex.en.md +5 -2
- package/docs/using-with-codex.md +3 -1
- package/docs/using-with-github-copilot.en.md +12 -6
- package/docs/using-with-github-copilot.md +8 -4
- package/lib/doctor-checks.js +178 -0
- package/lib/init.js +802 -29
- package/package.json +2 -2
- package/templates/brownfield/references/init-project-flow.md +3 -1
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +8 -1
- package/templates/greenfield/references/init-project-flow.md +5 -5
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +8 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
|
1225
|
-
|
|
1226
|
-
items
|
|
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(
|
|
1291
|
+
const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(packagedGuide) : [];
|
|
1234
1292
|
|
|
1235
1293
|
for (const agent of answers.aiAgents) {
|
|
1236
|
-
await addAiAgentShim(cwd, items, agent, substitution, {
|
|
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
|
-
|
|
2521
|
-
|
|
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
|
};
|