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.
- package/CHANGELOG.md +155 -0
- package/CONTRIBUTING.md +6 -9
- package/README.en.md +117 -40
- package/README.md +47 -15
- package/TEMPLATE-COVERAGE.md +3 -2
- package/bin/dflow.js +80 -3
- package/docs/evaluating-dflow.en.md +21 -2
- package/docs/evaluating-dflow.md +17 -3
- package/docs/npm-publish-checklist.md +3 -1
- package/docs/release-versioning-policy.md +3 -2
- package/docs/using-with-claude-code.en.md +35 -22
- package/docs/using-with-claude-code.md +27 -17
- package/docs/using-with-codex.en.md +20 -10
- package/docs/using-with-codex.md +13 -8
- package/docs/using-with-github-copilot.en.md +20 -9
- package/docs/using-with-github-copilot.md +14 -7
- package/lib/doctor-checks.js +178 -0
- package/lib/init.js +894 -36
- package/lib/render.js +1263 -0
- package/package.json +5 -2
- package/templates/brownfield/references/init-project-flow.md +46 -2
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +13 -1
- package/templates/brownfield/templates/_index.md +2 -0
- package/templates/brownfield/templates/context-definition.md +2 -0
- package/templates/brownfield/templates/context-map.md +1 -0
- package/templates/brownfield/templates/glossary.md +1 -0
- package/templates/brownfield/templates/models.md +1 -0
- package/templates/brownfield/templates/phase-spec.md +2 -0
- package/templates/brownfield/templates/rules.md +1 -0
- package/templates/brownfield/templates/tech-debt.md +1 -0
- package/templates/greenfield/references/init-project-flow.md +48 -6
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +13 -1
- package/templates/greenfield/templates/_index.md +2 -0
- package/templates/greenfield/templates/aggregate-design.md +2 -0
- package/templates/greenfield/templates/context-definition.md +2 -0
- package/templates/greenfield/templates/context-map.md +1 -0
- package/templates/greenfield/templates/events.md +1 -0
- package/templates/greenfield/templates/glossary.md +1 -0
- package/templates/greenfield/templates/models.md +1 -0
- package/templates/greenfield/templates/phase-spec.md +2 -0
- package/templates/greenfield/templates/rules.md +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
1151
|
-
|
|
1152
|
-
items
|
|
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(
|
|
1291
|
+
const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(packagedGuide) : [];
|
|
1160
1292
|
|
|
1161
1293
|
for (const agent of answers.aiAgents) {
|
|
1162
|
-
await addAiAgentShim(cwd, items, agent, substitution, {
|
|
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
|
-
|
|
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
|
-
|
|
2443
|
-
|
|
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
|
-
|
|
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
|
};
|