dflow-sdd-ddd 0.8.0 → 0.10.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 +100 -0
- package/LICENSE +679 -21
- package/README.en.md +24 -12
- package/README.md +15 -8
- package/TEMPLATE-COVERAGE.md +0 -1
- package/bin/dflow.js +4 -3
- package/docs/evaluating-dflow.en.md +11 -7
- package/docs/evaluating-dflow.md +9 -4
- package/docs/migrating-to-dflow-v1.md +7 -3
- package/docs/using-with-claude-code.en.md +40 -23
- package/docs/using-with-claude-code.md +34 -23
- package/docs/using-with-codex.en.md +135 -48
- package/docs/using-with-codex.md +99 -38
- package/docs/using-with-github-copilot.en.md +135 -34
- package/docs/using-with-github-copilot.md +120 -43
- package/lib/init.js +943 -145
- package/package.json +3 -3
- package/templates/brownfield/references/dflow-feedback-flow.md +135 -63
- package/templates/brownfield/references/drift-verification.md +1 -4
- package/templates/brownfield/references/finish-feature-flow.md +59 -23
- package/templates/brownfield/references/git-integration.md +65 -7
- package/templates/brownfield/references/init-project-flow.md +67 -36
- package/templates/brownfield/references/modify-existing-flow.md +10 -38
- package/templates/brownfield/references/new-feature-flow.md +28 -11
- package/templates/brownfield/references/new-phase-flow.md +16 -1
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +253 -2
- package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +5 -8
- package/templates/brownfield/scaffolding/Git-principles-gitflow.md +13 -12
- package/templates/brownfield/scaffolding/Git-principles-trunk.md +13 -16
- package/templates/brownfield/scaffolding/_conventions.md +10 -9
- package/templates/brownfield/templates/_index.md +21 -3
- package/templates/brownfield/templates/lightweight-spec.md +1 -1
- package/templates/brownfield/templates/phase-spec.md +1 -1
- package/templates/common/skill/SKILL.md +9 -6
- package/templates/greenfield/references/dflow-feedback-flow.md +135 -63
- package/templates/greenfield/references/drift-verification.md +1 -4
- package/templates/greenfield/references/finish-feature-flow.md +58 -23
- package/templates/greenfield/references/git-integration.md +65 -7
- package/templates/greenfield/references/init-project-flow.md +67 -36
- package/templates/greenfield/references/modify-existing-flow.md +9 -7
- package/templates/greenfield/references/new-feature-flow.md +29 -12
- package/templates/greenfield/references/new-phase-flow.md +16 -1
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +222 -2
- package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +10 -15
- package/templates/greenfield/scaffolding/Git-principles-gitflow.md +13 -12
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +14 -18
- package/templates/greenfield/scaffolding/_conventions.md +9 -8
- package/templates/greenfield/templates/_index.md +21 -3
- package/templates/greenfield/templates/lightweight-spec.md +1 -1
- package/templates/greenfield/templates/phase-spec.md +1 -1
- package/templates/brownfield/templates/CLAUDE.md +0 -165
- package/templates/greenfield/templates/CLAUDE.md +0 -172
package/lib/init.js
CHANGED
|
@@ -13,6 +13,16 @@ const COMMAND_REGISTRY_END = '<!-- dflow-command-registry:end -->';
|
|
|
13
13
|
const COMMAND_ADAPTER_GENERATED_MARKER = '<!-- dflow-generated: command-adapter -->';
|
|
14
14
|
const SKILL_ADAPTER_GENERATED_MARKER = '<!-- dflow-generated: skill-adapter -->';
|
|
15
15
|
const WORKFLOW_BUNDLE_GENERATED_MARKER = '<!-- dflow-generated: workflow-bundle -->';
|
|
16
|
+
const CODEX_TRIGGER_SECTION_START = '<!-- dflow-generated: codex-command-triggers START -->';
|
|
17
|
+
const CODEX_TRIGGER_SECTION_END = '<!-- dflow-generated: codex-command-triggers END -->';
|
|
18
|
+
// PROPOSAL-054: marker pair that wraps the Dflow base shim when it is appended
|
|
19
|
+
// into a user-owned root agent file (CLAUDE.md / AGENTS.md / copilot-instructions).
|
|
20
|
+
// It is ONLY used for the in-user-file block; a whole-file Dflow shim (freshly
|
|
21
|
+
// created, or a pristine 0.8.0/0.9.0 shim) stays marker-free and is recognized by
|
|
22
|
+
// the normalized template match (isPristineDflowAgentsShim), so we never have to
|
|
23
|
+
// freeze the pre-marker shim template for back-compat.
|
|
24
|
+
const AGENT_SHIM_SECTION_START = '<!-- dflow-generated: agent-shim START -->';
|
|
25
|
+
const AGENT_SHIM_SECTION_END = '<!-- dflow-generated: agent-shim END -->';
|
|
16
26
|
const WORKFLOW_BUNDLE_DEST = 'dflow/specs/shared/dflow-workflows';
|
|
17
27
|
const WORKFLOW_BUNDLE_MANIFEST_PATH = `${WORKFLOW_BUNDLE_DEST}/.dflow-bundle-manifest.json`;
|
|
18
28
|
const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
|
|
@@ -110,16 +120,42 @@ const OPTIONAL_FILE_OPTIONS = [
|
|
|
110
120
|
key: 'overview',
|
|
111
121
|
label: '_overview.md - system overview',
|
|
112
122
|
aliases: ['overview', '_overview.md']
|
|
123
|
+
}
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Git policy is a mandatory team choice (PROPOSAL-047): both options use feature
|
|
127
|
+
// branches; the choice selects the finish-stage merge guidance and drives the
|
|
128
|
+
// runtime branch gates / commit checkpoints.
|
|
129
|
+
const GIT_POLICY_OPTIONS = [
|
|
130
|
+
{
|
|
131
|
+
key: 'gitflow',
|
|
132
|
+
label: 'Git Flow - long-lived develop/release branches',
|
|
133
|
+
aliases: ['gitflow', 'git flow', 'flow']
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: 'trunk',
|
|
137
|
+
label: 'Trunk / GitHub Flow - short-lived feature branches (lightest)',
|
|
138
|
+
aliases: ['trunk', 'trunk-based', 'github flow', 'githubflow']
|
|
139
|
+
}
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// How AI-made commits are marked (PROPOSAL-047). Chosen once at init; the
|
|
143
|
+
// runtime does not re-ask. None is the default.
|
|
144
|
+
const AI_COMMIT_MARKER_OPTIONS = [
|
|
145
|
+
{
|
|
146
|
+
key: 'none',
|
|
147
|
+
label: 'None - AI commits look like any other commit',
|
|
148
|
+
aliases: ['none', 'off', 'no']
|
|
113
149
|
},
|
|
114
150
|
{
|
|
115
|
-
key: '
|
|
116
|
-
label: '
|
|
117
|
-
aliases: ['
|
|
151
|
+
key: 'co-authored-by',
|
|
152
|
+
label: 'Co-Authored-By trailer (dflow-ai) - filterable / auditable',
|
|
153
|
+
aliases: ['co-authored-by', 'co-author', 'trailer', 'coauthored']
|
|
118
154
|
},
|
|
119
155
|
{
|
|
120
|
-
key: '
|
|
121
|
-
label: '
|
|
122
|
-
aliases: ['
|
|
156
|
+
key: 'prefix',
|
|
157
|
+
label: '[ai-assisted] commit-message prefix - visible at a glance',
|
|
158
|
+
aliases: ['prefix', 'ai-assisted', '[ai-assisted]']
|
|
123
159
|
}
|
|
124
160
|
];
|
|
125
161
|
|
|
@@ -208,7 +244,7 @@ async function runInit(options = {}) {
|
|
|
208
244
|
const detection = await detectProjectSignals(cwd);
|
|
209
245
|
const answers = await promptForAnswers(rl, stdout, stderr, detection);
|
|
210
246
|
const plan = await buildFilePlan(cwd, answers);
|
|
211
|
-
const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.bundleWarnings || [])];
|
|
247
|
+
const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.warnings || []), ...(plan.bundleWarnings || [])];
|
|
212
248
|
|
|
213
249
|
renderPreview(stdout, plan, warnings);
|
|
214
250
|
const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
|
|
@@ -311,7 +347,8 @@ async function runConfigureAgents(options = {}) {
|
|
|
311
347
|
result.warnings.push(...collectUnresolvedPlaceholderWarnings(plan, result.created));
|
|
312
348
|
|
|
313
349
|
printResultReport(stdout, result, plan.deferred);
|
|
314
|
-
|
|
350
|
+
const usedSnippetFallback = plan.items.some((item) => item.snippetFallback);
|
|
351
|
+
printConfigureAgentsNextSteps(stdout, Boolean(options.commandAdapters), usedSnippetFallback);
|
|
315
352
|
return 0;
|
|
316
353
|
} catch (error) {
|
|
317
354
|
if (rl) {
|
|
@@ -503,7 +540,13 @@ async function detectConfiguredAgents(cwd) {
|
|
|
503
540
|
// can default to them instead of re-asking from scratch on every invocation.
|
|
504
541
|
// Order matches AI_AGENT_OPTIONS so the prompt numbering lines up.
|
|
505
542
|
const detected = [];
|
|
506
|
-
if (
|
|
543
|
+
if (
|
|
544
|
+
(await pathExists(path.join(cwd, 'AGENTS.md'))) ||
|
|
545
|
+
// PROPOSAL-056 Phase 1: a project-level Codex skill counts as configured so
|
|
546
|
+
// re-runs default to the `agents` target, matching the .claude/skills check
|
|
547
|
+
// below for Claude.
|
|
548
|
+
(await pathExists(path.join(cwd, '.agents/skills/dflow')))
|
|
549
|
+
) {
|
|
507
550
|
detected.push('agents');
|
|
508
551
|
}
|
|
509
552
|
if (
|
|
@@ -513,7 +556,12 @@ async function detectConfiguredAgents(cwd) {
|
|
|
513
556
|
) {
|
|
514
557
|
detected.push('claude');
|
|
515
558
|
}
|
|
516
|
-
if (
|
|
559
|
+
if (
|
|
560
|
+
(await pathExists(path.join(cwd, '.github/copilot-instructions.md'))) ||
|
|
561
|
+
// A project-level Copilot skill counts as configured too (parity with the
|
|
562
|
+
// .claude/skills and .agents/skills checks above).
|
|
563
|
+
(await pathExists(path.join(cwd, '.github/skills/dflow')))
|
|
564
|
+
) {
|
|
517
565
|
detected.push('copilot');
|
|
518
566
|
}
|
|
519
567
|
return detected;
|
|
@@ -612,6 +660,20 @@ async function promptForAnswers(rl, stdout, stderr, detection) {
|
|
|
612
660
|
proseLanguage = await askCustomProseLanguage(rl, stderr);
|
|
613
661
|
}
|
|
614
662
|
|
|
663
|
+
const gitPolicy = await askSelect(rl, stdout, stderr, {
|
|
664
|
+
id: 'Q5',
|
|
665
|
+
question: 'Which Git policy does the team follow? (drives branch gates and finish-stage merge guidance)',
|
|
666
|
+
options: GIT_POLICY_OPTIONS,
|
|
667
|
+
defaultKey: null
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const aiCommitMarker = await askSelect(rl, stdout, stderr, {
|
|
671
|
+
id: 'Q6',
|
|
672
|
+
question: 'How should AI-made commits be marked? (the AI offers to commit at checkpoints; you can always decline)',
|
|
673
|
+
options: AI_COMMIT_MARKER_OPTIONS,
|
|
674
|
+
defaultKey: 'none'
|
|
675
|
+
});
|
|
676
|
+
|
|
615
677
|
const optionalFiles = await askOptionalFiles(rl, stdout, stderr);
|
|
616
678
|
const aiAgents = await askAiAgents(rl, stdout, stderr, detection.configuredAgents || []);
|
|
617
679
|
|
|
@@ -621,6 +683,8 @@ async function promptForAnswers(rl, stdout, stderr, detection) {
|
|
|
621
683
|
techStackSummary,
|
|
622
684
|
migrationContext,
|
|
623
685
|
proseLanguage,
|
|
686
|
+
gitPolicy,
|
|
687
|
+
aiCommitMarker,
|
|
624
688
|
optionalFiles,
|
|
625
689
|
aiAgents
|
|
626
690
|
};
|
|
@@ -645,10 +709,26 @@ async function inferProjectContext(cwd, rl, stdout, stderr) {
|
|
|
645
709
|
techStackSummary: await inferTechStackSummary(cwd),
|
|
646
710
|
migrationContext: await inferMigrationContext(cwd),
|
|
647
711
|
proseLanguage: await inferProseLanguage(cwd),
|
|
712
|
+
gitPolicy: await inferGitPolicy(cwd),
|
|
713
|
+
aiCommitMarker: await inferAiCommitMarker(cwd),
|
|
648
714
|
optionalFiles: []
|
|
649
715
|
};
|
|
650
716
|
}
|
|
651
717
|
|
|
718
|
+
async function inferGitPolicy(cwd) {
|
|
719
|
+
const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
|
|
720
|
+
const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
|
|
721
|
+
const match = content.match(/Selected Git policy:\s*`([^`]+)`/);
|
|
722
|
+
return match ? match[1] : null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function inferAiCommitMarker(cwd) {
|
|
726
|
+
const conventionsPath = path.join(cwd, 'dflow/specs/shared/_conventions.md');
|
|
727
|
+
const content = await fs.readFile(conventionsPath, 'utf8').catch(() => '');
|
|
728
|
+
const match = content.match(/AI commit marker:\s*`([^`]+)`/);
|
|
729
|
+
return match ? match[1] : null;
|
|
730
|
+
}
|
|
731
|
+
|
|
652
732
|
async function inferExistingEdition(cwd) {
|
|
653
733
|
if (await pathExists(path.join(cwd, 'dflow/specs/architecture/tech-debt.md'))) {
|
|
654
734
|
return 'greenfield';
|
|
@@ -767,33 +847,22 @@ async function askOptionalFiles(rl, stdout, stderr) {
|
|
|
767
847
|
while (true) {
|
|
768
848
|
stdout.write('\nWhich optional starter files should Dflow seed?\n');
|
|
769
849
|
OPTIONAL_FILE_OPTIONS.forEach((option, index) => {
|
|
770
|
-
const defaultMarker = option.key === 'overview'
|
|
850
|
+
const defaultMarker = option.key === 'overview' ? ' (recommended)' : '';
|
|
771
851
|
stdout.write(` ${index + 1}. ${option.label}${defaultMarker}\n`);
|
|
772
852
|
});
|
|
773
853
|
|
|
774
|
-
const answer = await askLine(rl, 'Enter comma-separated choices, "none", or press Enter for recommended [1
|
|
775
|
-
const parsed = parseMultiselectAnswer(answer, OPTIONAL_FILE_OPTIONS, ['overview'
|
|
854
|
+
const answer = await askLine(rl, 'Enter comma-separated choices, "none", or press Enter for recommended [1]: ');
|
|
855
|
+
const parsed = parseMultiselectAnswer(answer, OPTIONAL_FILE_OPTIONS, ['overview']);
|
|
776
856
|
|
|
777
857
|
if (!parsed.valid) {
|
|
778
858
|
failedAttempts += 1;
|
|
779
859
|
if (failedAttempts >= 3) {
|
|
780
|
-
throw new InitError('Too many invalid attempts for
|
|
860
|
+
throw new InitError('Too many invalid attempts for Q7. Dflow init aborted.');
|
|
781
861
|
}
|
|
782
862
|
stderr.write(`${parsed.message} (${3 - failedAttempts} attempts left)\n`);
|
|
783
863
|
continue;
|
|
784
864
|
}
|
|
785
865
|
|
|
786
|
-
if (parsed.values.includes('git-trunk') && parsed.values.includes('git-flow')) {
|
|
787
|
-
const keepBoth = await askConfirmation(
|
|
788
|
-
rl,
|
|
789
|
-
'You selected both Git principles templates. Most projects choose one. Keep both? (y/N) '
|
|
790
|
-
);
|
|
791
|
-
if (!keepBoth) {
|
|
792
|
-
failedAttempts = 0;
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
866
|
return parsed.values;
|
|
798
867
|
}
|
|
799
868
|
}
|
|
@@ -820,7 +889,7 @@ async function askAiAgents(rl, stdout, stderr, defaultKeys = []) {
|
|
|
820
889
|
if (!parsed.valid) {
|
|
821
890
|
failedAttempts += 1;
|
|
822
891
|
if (failedAttempts >= 3) {
|
|
823
|
-
throw new InitError('Too many invalid attempts for
|
|
892
|
+
throw new InitError('Too many invalid attempts for Q8. Dflow init aborted.');
|
|
824
893
|
}
|
|
825
894
|
stderr.write(`${parsed.message} (${3 - failedAttempts} attempts left)\n`);
|
|
826
895
|
continue;
|
|
@@ -997,6 +1066,7 @@ async function buildFilePlan(cwd, answers) {
|
|
|
997
1066
|
content = substitutePlaceholders(content, substitution);
|
|
998
1067
|
if (options.injectProseLanguage) {
|
|
999
1068
|
content = ensureProseLanguageSection(content, answers.proseLanguage);
|
|
1069
|
+
content = ensureConventionPolicySections(content, answers);
|
|
1000
1070
|
}
|
|
1001
1071
|
items.push({
|
|
1002
1072
|
relativePath,
|
|
@@ -1030,17 +1100,25 @@ async function buildFilePlan(cwd, answers) {
|
|
|
1030
1100
|
if (answers.optionalFiles.includes('overview')) {
|
|
1031
1101
|
await addTemplate('dflow/specs/shared/_overview.md', 'scaffolding/_overview.md', 'selected');
|
|
1032
1102
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
if (answers.
|
|
1037
|
-
await addTemplate('dflow/specs/shared/Git-principles-gitflow.md', 'scaffolding/Git-principles-gitflow.md', 'selected');
|
|
1103
|
+
|
|
1104
|
+
// PROPOSAL-047: the selected Git policy is mandatory — always project exactly
|
|
1105
|
+
// the matching Git-principles file so the runtime branch gates have a policy.
|
|
1106
|
+
if (answers.gitPolicy === 'gitflow') {
|
|
1107
|
+
await addTemplate('dflow/specs/shared/Git-principles-gitflow.md', 'scaffolding/Git-principles-gitflow.md', 'mandatory, selected Git policy');
|
|
1108
|
+
} else {
|
|
1109
|
+
await addTemplate('dflow/specs/shared/Git-principles-trunk.md', 'scaffolding/Git-principles-trunk.md', 'mandatory, selected Git policy');
|
|
1038
1110
|
}
|
|
1039
1111
|
|
|
1112
|
+
// PROPOSAL-054: agent-shim auto-inject can emit fallback warnings (marker
|
|
1113
|
+
// conflict). init previously never plumbed these — buildConfigureAgentsPlan had
|
|
1114
|
+
// a warnings accumulator but buildFilePlan did not — so a fallback during init
|
|
1115
|
+
// was silent. Collect them here and surface them in runInit alongside the
|
|
1116
|
+
// preflight / detection / bundle warnings.
|
|
1117
|
+
const warnings = [];
|
|
1040
1118
|
if (answers.aiAgents.length > 0) {
|
|
1041
1119
|
await addTemplate('dflow/specs/shared/AI-AGENT-GUIDE.md', 'scaffolding/AI-AGENT-GUIDE.md', 'selected, canonical AI agent guide');
|
|
1042
1120
|
for (const agent of answers.aiAgents) {
|
|
1043
|
-
await addAiAgentShim(cwd, items, agent, substitution);
|
|
1121
|
+
await addAiAgentShim(cwd, items, agent, substitution, { warnings });
|
|
1044
1122
|
}
|
|
1045
1123
|
}
|
|
1046
1124
|
|
|
@@ -1054,6 +1132,7 @@ async function buildFilePlan(cwd, answers) {
|
|
|
1054
1132
|
items,
|
|
1055
1133
|
deferred: buildDeferredItems(answers.edition),
|
|
1056
1134
|
bundleWarnings,
|
|
1135
|
+
warnings,
|
|
1057
1136
|
unresolvedInitPlaceholders: Array.from(substitution.entries())
|
|
1058
1137
|
.filter(([placeholder, value]) => placeholder === value)
|
|
1059
1138
|
.map(([placeholder]) => placeholder)
|
|
@@ -1080,7 +1159,7 @@ async function buildConfigureAgentsPlan(cwd, answers) {
|
|
|
1080
1159
|
const commandRegistry = answers.commandAdapters ? parseDflowCommandRegistry(content) : [];
|
|
1081
1160
|
|
|
1082
1161
|
for (const agent of answers.aiAgents) {
|
|
1083
|
-
await addAiAgentShim(cwd, items, agent, substitution, { commandRegistry });
|
|
1162
|
+
await addAiAgentShim(cwd, items, agent, substitution, { commandRegistry, warnings });
|
|
1084
1163
|
}
|
|
1085
1164
|
|
|
1086
1165
|
if (answers.commandAdapters) {
|
|
@@ -1115,6 +1194,16 @@ async function buildConfigureAgentsPlan(cwd, answers) {
|
|
|
1115
1194
|
|
|
1116
1195
|
async function finalizePlanItems(cwd, items) {
|
|
1117
1196
|
for (const item of items) {
|
|
1197
|
+
// PROPOSAL-054: items whose action was already decided at plan time from the
|
|
1198
|
+
// existing file content (root agent-file append / replace / skip / fallback)
|
|
1199
|
+
// must not be re-derived from overwrite+existence here — that would discard
|
|
1200
|
+
// the marked-region decision. Just make sure size is populated.
|
|
1201
|
+
if (item.action) {
|
|
1202
|
+
if (item.size === undefined) {
|
|
1203
|
+
item.size = Buffer.byteLength(item.content || '', 'utf8');
|
|
1204
|
+
}
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1118
1207
|
const absolute = path.join(cwd, item.relativePath);
|
|
1119
1208
|
const targetExists = await pathExists(absolute);
|
|
1120
1209
|
item.action = targetExists ? (item.overwrite ? 'update' : 'skip') : 'create';
|
|
@@ -1153,14 +1242,54 @@ async function listBundleSourceFiles(edition) {
|
|
|
1153
1242
|
return files;
|
|
1154
1243
|
}
|
|
1155
1244
|
|
|
1245
|
+
// Reads the per-project workflow bundle manifest, distinguishing "absent"
|
|
1246
|
+
// (normal: fresh init / first projection) from "corrupt" (unreadable or invalid
|
|
1247
|
+
// shape). A corrupt manifest must NOT be treated as absent: that would silently
|
|
1248
|
+
// disable stale cleanup and then overwrite the (recoverable) record. Callers
|
|
1249
|
+
// degrade on corrupt — skip cleanup, skip the manifest write, still project —
|
|
1250
|
+
// rather than hard-fail, because a corrupt project manifest is a user-project
|
|
1251
|
+
// state, not a broken package. An empty `files: []` is a valid manifest.
|
|
1156
1252
|
async function readCurrentBundleManifest(cwd) {
|
|
1157
1253
|
const manifestPath = path.join(cwd, WORKFLOW_BUNDLE_MANIFEST_PATH);
|
|
1254
|
+
let raw;
|
|
1255
|
+
try {
|
|
1256
|
+
raw = await fs.readFile(manifestPath, 'utf8');
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
if (error.code === 'ENOENT') {
|
|
1259
|
+
return { kind: 'absent' };
|
|
1260
|
+
}
|
|
1261
|
+
return { kind: 'corrupt', reason: `cannot read manifest (${error.code || error.message})` };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
let parsed;
|
|
1158
1265
|
try {
|
|
1159
|
-
|
|
1160
|
-
return JSON.parse(raw);
|
|
1266
|
+
parsed = JSON.parse(raw);
|
|
1161
1267
|
} catch {
|
|
1162
|
-
return
|
|
1268
|
+
return { kind: 'corrupt', reason: 'manifest is not valid JSON' };
|
|
1269
|
+
}
|
|
1270
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1271
|
+
return { kind: 'corrupt', reason: 'manifest is not a JSON object' };
|
|
1272
|
+
}
|
|
1273
|
+
if (!Array.isArray(parsed.files) || !parsed.files.every((entry) => typeof entry === 'string')) {
|
|
1274
|
+
return { kind: 'corrupt', reason: 'manifest "files" is not an array of strings' };
|
|
1163
1275
|
}
|
|
1276
|
+
return { kind: 'ok', manifest: parsed };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// True when a manifest `files` entry is a canonical, in-bundle, traversal-free
|
|
1280
|
+
// relative path. Manifest entries are produced canonically; a non-canonical
|
|
1281
|
+
// entry (hand-edited / corrupt manifest, e.g. one containing "..") is NOT acted
|
|
1282
|
+
// on, because (a) it cannot be reliably string-matched against the current
|
|
1283
|
+
// bundle set — so a path that *resolves* to a current file would otherwise be
|
|
1284
|
+
// scheduled for removal — and (b) it drives an unlink. Verifying canonical form
|
|
1285
|
+
// up front both blocks traversal and makes the newRelPaths string compare
|
|
1286
|
+
// reliable.
|
|
1287
|
+
function isCanonicalBundlePath(relPath) {
|
|
1288
|
+
if (typeof relPath !== 'string' || relPath.length === 0) return false;
|
|
1289
|
+
if (relPath.includes('\\')) return false;
|
|
1290
|
+
if (path.posix.normalize(relPath) !== relPath) return false;
|
|
1291
|
+
const prefix = `${WORKFLOW_BUNDLE_DEST}/`;
|
|
1292
|
+
return relPath.startsWith(prefix) && relPath.length > prefix.length;
|
|
1164
1293
|
}
|
|
1165
1294
|
|
|
1166
1295
|
function buildBundleManifest(edition, version, files) {
|
|
@@ -1179,44 +1308,90 @@ function injectBundleMarker(content) {
|
|
|
1179
1308
|
async function addWorkflowBundleItems(cwd, items, warnings, edition) {
|
|
1180
1309
|
const bundleFiles = await listBundleSourceFiles(edition);
|
|
1181
1310
|
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1184
|
-
|
|
1311
|
+
// R3-02: a healthy edition's bundle source is never empty. An empty scan means
|
|
1312
|
+
// a broken installed package — hard-fail BEFORE scheduling any removal or
|
|
1313
|
+
// writing the manifest, so we never overwrite the manifest with files:[]
|
|
1314
|
+
// (which would orphan every projected file and ship a workflow-less project).
|
|
1315
|
+
if (bundleFiles.length === 0) {
|
|
1316
|
+
throw new InitError(
|
|
1317
|
+
`Internal error: no workflow bundle source files found for edition "${edition}" (expected files under templates/${edition}/references/ and templates/${edition}/templates/). The installed dflow package looks incomplete.`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1185
1320
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1321
|
+
const newRelPaths = new Set(bundleFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
|
|
1322
|
+
|
|
1323
|
+
// Read the previous manifest to drive stale cleanup. Distinguish absent
|
|
1324
|
+
// (normal) from corrupt (degrade): on corrupt, skip cleanup AND skip the
|
|
1325
|
+
// manifest write below — never hard-fail, since a corrupt project manifest is
|
|
1326
|
+
// a user-project state (the package may be healthy and the user may be running
|
|
1327
|
+
// configure-agents precisely to repair / update).
|
|
1328
|
+
const manifestResult = await readCurrentBundleManifest(cwd);
|
|
1329
|
+
const manifestCorrupt = manifestResult.kind === 'corrupt';
|
|
1330
|
+
if (manifestCorrupt) {
|
|
1331
|
+
warnings.push(
|
|
1332
|
+
`Workflow bundle manifest is unreadable (${manifestResult.reason}); skipped stale cleanup and left the manifest untouched. Delete ${WORKFLOW_BUNDLE_MANIFEST_PATH} and re-run to rebuild it.`
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
const existingManifest = manifestResult.kind === 'ok' ? manifestResult.manifest : null;
|
|
1336
|
+
|
|
1337
|
+
// Stale removal (generalized from edition-change-only to a manifest diff):
|
|
1338
|
+
// remove any path the previous manifest recorded but the current bundle source
|
|
1339
|
+
// no longer includes. Edition change is just the case where the whole old set
|
|
1340
|
+
// differs; a same-edition file-set shrink (e.g. a retired bundle file) is
|
|
1341
|
+
// handled identically. The marker check (here) + content re-check (apply
|
|
1342
|
+
// phase) still guard against deleting user-modified files.
|
|
1343
|
+
if (existingManifest) {
|
|
1344
|
+
const previousEdition = existingManifest.edition;
|
|
1345
|
+
const editionChanged = Boolean(previousEdition) && previousEdition !== edition;
|
|
1346
|
+
for (const staleRelPath of existingManifest.files) {
|
|
1347
|
+
// Act only on canonical, in-bundle, traversal-free entries. A
|
|
1348
|
+
// non-canonical entry (hand-edited / corrupt manifest) is skipped: acting
|
|
1349
|
+
// on it would make the newRelPaths string compare unreliable (a path that
|
|
1350
|
+
// resolves to a current bundle file could be scheduled for removal) and it
|
|
1351
|
+
// drives an unlink. Checked FIRST so the membership compare below is sound.
|
|
1352
|
+
if (!isCanonicalBundlePath(staleRelPath)) {
|
|
1353
|
+
warnings.push(
|
|
1354
|
+
`Ignored non-canonical workflow bundle manifest path: ${staleRelPath}`
|
|
1355
|
+
);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
// Still a current bundle file → it will be updated, not removed.
|
|
1359
|
+
if (newRelPaths.has(staleRelPath)) {
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1190
1362
|
const staleAbsPath = path.join(cwd, staleRelPath);
|
|
1191
|
-
let
|
|
1363
|
+
let staleStat = null;
|
|
1192
1364
|
try {
|
|
1193
|
-
await fs.stat(staleAbsPath);
|
|
1194
|
-
staleExists = true;
|
|
1365
|
+
staleStat = await fs.stat(staleAbsPath);
|
|
1195
1366
|
} catch {
|
|
1196
|
-
|
|
1367
|
+
staleStat = null;
|
|
1368
|
+
}
|
|
1369
|
+
if (!staleStat) {
|
|
1370
|
+
continue;
|
|
1197
1371
|
}
|
|
1198
|
-
|
|
1372
|
+
// A non-file entry (e.g. a directory path in a hand-edited / corrupt
|
|
1373
|
+
// manifest) must degrade gracefully, not crash readFile with EISDIR.
|
|
1374
|
+
if (!staleStat.isFile()) {
|
|
1375
|
+
warnings.push(`Ignored non-file workflow bundle manifest entry: ${staleRelPath}`);
|
|
1199
1376
|
continue;
|
|
1200
1377
|
}
|
|
1201
1378
|
const staleContent = await fs.readFile(staleAbsPath, 'utf8');
|
|
1202
1379
|
if (!staleContent.includes(WORKFLOW_BUNDLE_GENERATED_MARKER)) {
|
|
1203
1380
|
warnings.push(
|
|
1204
|
-
`
|
|
1381
|
+
`Retired workflow bundle file is user-modified; left unchanged: ${staleRelPath}`
|
|
1205
1382
|
);
|
|
1206
1383
|
continue;
|
|
1207
1384
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
});
|
|
1219
|
-
}
|
|
1385
|
+
items.push({
|
|
1386
|
+
relativePath: staleRelPath,
|
|
1387
|
+
source: editionChanged ? `stale-bundle:${previousEdition}` : 'stale-bundle:retired',
|
|
1388
|
+
notes: editionChanged
|
|
1389
|
+
? `stale workflow bundle file from ${previousEdition} edition`
|
|
1390
|
+
: 'retired workflow bundle file no longer in the package source',
|
|
1391
|
+
action: 'remove',
|
|
1392
|
+
size: Buffer.byteLength(staleContent, 'utf8'),
|
|
1393
|
+
expectedContent: staleContent
|
|
1394
|
+
});
|
|
1220
1395
|
}
|
|
1221
1396
|
}
|
|
1222
1397
|
|
|
@@ -1257,23 +1432,27 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
|
|
|
1257
1432
|
});
|
|
1258
1433
|
}
|
|
1259
1434
|
|
|
1260
|
-
// Add the manifest file
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1435
|
+
// Add the manifest file — unless the existing manifest is corrupt, in which
|
|
1436
|
+
// case leave it untouched (overwriting would destroy the only recoverable
|
|
1437
|
+
// record and silently recreate the manifest-orphan problem).
|
|
1438
|
+
if (!manifestCorrupt) {
|
|
1439
|
+
const manifestContent = JSON.stringify(
|
|
1440
|
+
buildBundleManifest(edition, pkg.version, bundleFiles),
|
|
1441
|
+
null,
|
|
1442
|
+
2
|
|
1443
|
+
) + '\n';
|
|
1444
|
+
const manifestExists = await pathExists(path.join(cwd, WORKFLOW_BUNDLE_MANIFEST_PATH));
|
|
1267
1445
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1446
|
+
items.push({
|
|
1447
|
+
relativePath: WORKFLOW_BUNDLE_MANIFEST_PATH,
|
|
1448
|
+
source: `generated:workflow-bundle-manifest`,
|
|
1449
|
+
notes: 'workflow bundle manifest',
|
|
1450
|
+
content: manifestContent,
|
|
1451
|
+
action: manifestExists ? 'update' : 'create',
|
|
1452
|
+
overwrite: true,
|
|
1453
|
+
size: Buffer.byteLength(manifestContent, 'utf8')
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1277
1456
|
}
|
|
1278
1457
|
|
|
1279
1458
|
async function readPackagedBundleFile(edition, sourceRel) {
|
|
@@ -1302,45 +1481,355 @@ async function readPackagedBundleFile(edition, sourceRel) {
|
|
|
1302
1481
|
}
|
|
1303
1482
|
}
|
|
1304
1483
|
|
|
1484
|
+
// PROPOSAL-054: configure a tool's root agent file. A non-guide existing file used
|
|
1485
|
+
// to be parked as a side merge snippet ("hand-merge this yourself"); it is now an
|
|
1486
|
+
// auto-injected, marker-delimited Dflow block shown in the confirmation preview.
|
|
1487
|
+
// The snippet + warning survive only as a genuine-conflict fallback. Decision table
|
|
1488
|
+
// (read existing file content, then branch):
|
|
1489
|
+
// 1. not exists -> create marker-free whole-file shim
|
|
1490
|
+
// 2a. pristine / prior whole-file shim -> regenerate in place (idempotent / migrate)
|
|
1491
|
+
// 2b. one well-formed agent-shim block -> replace that block (idempotent re-run)
|
|
1492
|
+
// 2c. malformed Dflow markers -> snippet fallback + warning (file untouched)
|
|
1493
|
+
// 2d. references guide, not 2a/2b/2c -> skip base shim (Codex: still upsert trigger, OQ#6c)
|
|
1494
|
+
// 2e. user-owned, non-guide existing -> append the marked block(s) <- core change
|
|
1495
|
+
// For Codex + --command-adapters the base-shim block and the trigger block are two
|
|
1496
|
+
// adjacent, independently-marked regions assembled into ONE plan item (per-item
|
|
1497
|
+
// writes are whole-file, so two items for one path would clobber each other).
|
|
1305
1498
|
async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
|
|
1306
1499
|
const target = getAiAgentTarget(agent);
|
|
1307
1500
|
const targetPath = path.join(cwd, target.relativePath);
|
|
1308
|
-
const targetExists = await pathExists(targetPath);
|
|
1309
|
-
const targetConfigured = targetExists && await fileReferencesAiAgentGuide(targetPath);
|
|
1310
1501
|
const commandRegistry = options.commandRegistry || [];
|
|
1311
|
-
const
|
|
1312
|
-
const
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1502
|
+
const warnings = options.warnings;
|
|
1503
|
+
const isCodex = target.relativePath === 'AGENTS.md';
|
|
1504
|
+
const wantsTrigger = isCodex && commandRegistry.length > 0;
|
|
1505
|
+
const source = `generated:${agent}-shim`;
|
|
1506
|
+
|
|
1507
|
+
// Marker-free whole-file forms. `fullShim` is what a freshly created or regenerated
|
|
1508
|
+
// file contains (for Codex + --command-adapters it already embeds the trigger).
|
|
1509
|
+
// `baseShimBody` is the trigger-free shim wrapped in agent-shim markers when
|
|
1510
|
+
// appended into a user-owned file.
|
|
1511
|
+
const baseShimBody = substitutePlaceholders(buildAiAgentShim(target.relativePath), substitution);
|
|
1512
|
+
const fullShim = substitutePlaceholders(buildAiAgentShim(target.relativePath, commandRegistry), substitution);
|
|
1513
|
+
const agentShimBlock = wrapAgentShimBlock(baseShimBody);
|
|
1514
|
+
const triggerBlock = wantsTrigger
|
|
1515
|
+
? substitutePlaceholders(buildCodexCommandTriggerSection(commandRegistry), substitution).trim()
|
|
1516
|
+
: '';
|
|
1517
|
+
|
|
1518
|
+
// Case 1 — create the marker-free whole-file shim. A re-run recognizes it through
|
|
1519
|
+
// the normalized template match (case 2a), so it needs no marker of its own.
|
|
1520
|
+
if (!(await pathExists(targetPath))) {
|
|
1521
|
+
items.push({
|
|
1522
|
+
relativePath: target.relativePath,
|
|
1523
|
+
source,
|
|
1524
|
+
notes: 'selected, tool-specific shim',
|
|
1525
|
+
content: fullShim,
|
|
1526
|
+
action: 'create',
|
|
1527
|
+
size: Buffer.byteLength(fullShim, 'utf8')
|
|
1528
|
+
});
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const existingContent = await fs.readFile(targetPath, 'utf8');
|
|
1533
|
+
const eol = detectDominantEol(existingContent);
|
|
1534
|
+
const lf = existingContent.replace(/\r\n/g, '\n');
|
|
1535
|
+
const agentRegion = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
|
|
1536
|
+
// Classify the Codex trigger region on EVERY AGENTS.md run (not only when we are
|
|
1537
|
+
// about to manage the trigger): even a non---command-adapters run replaces the
|
|
1538
|
+
// agent-shim region, and a trigger region that overlaps it would be corrupted by
|
|
1539
|
+
// that slice. We only need the region for the safety gate below; trigger writes
|
|
1540
|
+
// still happen only when wantsTrigger.
|
|
1541
|
+
const triggerRegion = isCodex
|
|
1542
|
+
? classifyMarkedRegion(lf, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END)
|
|
1543
|
+
: { state: 'absent' };
|
|
1544
|
+
|
|
1545
|
+
// Case 2c — Dflow markers cannot be edited safely; fall back to a previewed merge
|
|
1546
|
+
// snippet + warning and leave the file untouched. Three unsafe shapes:
|
|
1547
|
+
// - the agent-shim marker pair is malformed (can't locate the block to manage);
|
|
1548
|
+
// - we are managing the trigger (--command-adapters) and the trigger pair is
|
|
1549
|
+
// malformed (can't locate the block to update);
|
|
1550
|
+
// - the agent-shim and trigger regions overlap / interleave, which the independent
|
|
1551
|
+
// region slices below would corrupt. This is checked on EVERY AGENTS.md run,
|
|
1552
|
+
// adapter or not, because case 2b slices the agent-shim region regardless.
|
|
1553
|
+
// A malformed trigger on a NON-adapter run is deliberately NOT a blanket fallback: we
|
|
1554
|
+
// never touch the trigger there. But slicing the agent-shim block IS unsafe when
|
|
1555
|
+
// trigger markers straddle its boundary (some inside, some outside) — removing the
|
|
1556
|
+
// inside one(s) can promote the remaining outside markers into a new well-formed
|
|
1557
|
+
// trigger region wrapping the regenerated block. `triggerStraddlesAgent` catches that
|
|
1558
|
+
// on every AGENTS.md run (a fully-inside set is cleaned with the block, a fully-
|
|
1559
|
+
// outside set is untouched — both safe). The fallback ALWAYS warns (also closing the
|
|
1560
|
+
// R2-02 asymmetry). When only the trigger pair is malformed in a guide-configured file
|
|
1561
|
+
// under --command-adapters (no agent/overlap/straddle issue), the base shim is already
|
|
1562
|
+
// present, so the fallback is the trigger-only snippet (OQ#6c).
|
|
1563
|
+
const regionsOverlap = agentRegion.state === 'present' && triggerRegion.state === 'present' &&
|
|
1564
|
+
agentRegion.startIdx < triggerRegion.endIdx && triggerRegion.startIdx < agentRegion.endIdx;
|
|
1565
|
+
const triggerStraddlesAgent = isCodex && agentRegion.state === 'present' &&
|
|
1566
|
+
codexTriggerMarkersStraddle(lf, agentRegion.startIdx, agentRegion.endIdx);
|
|
1567
|
+
if (agentRegion.state === 'malformed' || (wantsTrigger && triggerRegion.state === 'malformed') ||
|
|
1568
|
+
regionsOverlap || triggerStraddlesAgent) {
|
|
1569
|
+
const triggerOnlyFallback = wantsTrigger && triggerRegion.state === 'malformed' &&
|
|
1570
|
+
agentRegion.state !== 'malformed' && !regionsOverlap && !triggerStraddlesAgent &&
|
|
1571
|
+
contentReferencesAiAgentGuide(existingContent);
|
|
1572
|
+
const snippetPath = triggerOnlyFallback
|
|
1573
|
+
? 'dflow/specs/shared/AGENTS-md-command-adapters-snippet.md'
|
|
1574
|
+
: target.snippetPath;
|
|
1575
|
+
const snippetContent = triggerOnlyFallback
|
|
1576
|
+
? substitutePlaceholders(buildCodexCommandTriggerSection(commandRegistry), substitution)
|
|
1577
|
+
: fullShim;
|
|
1578
|
+
if (warnings) {
|
|
1579
|
+
warnings.push(
|
|
1580
|
+
`Existing ${target.relativePath} contains malformed Dflow markers; left it untouched and wrote ${snippetPath} for manual merge. Remove the stray Dflow markers and re-run to let Dflow manage the block.`
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
items.push({
|
|
1584
|
+
relativePath: snippetPath,
|
|
1585
|
+
source,
|
|
1586
|
+
notes: `selected, ${target.relativePath} has conflicting Dflow markers; merge this snippet manually`,
|
|
1587
|
+
content: snippetContent,
|
|
1588
|
+
overwrite: true,
|
|
1589
|
+
snippetFallback: true
|
|
1590
|
+
});
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Case 2b — exactly one well-formed agent-shim block: replace it in place. For
|
|
1595
|
+
// Codex + --command-adapters also (re)place the adjacent trigger block in the SAME
|
|
1596
|
+
// item.
|
|
1597
|
+
if (agentRegion.state === 'present') {
|
|
1598
|
+
let updated = lf.slice(0, agentRegion.startIdx) + agentShimBlock + lf.slice(agentRegion.endIdx);
|
|
1599
|
+
if (wantsTrigger) {
|
|
1600
|
+
updated = upsertCodexTriggerBlock(updated, triggerBlock);
|
|
1601
|
+
}
|
|
1602
|
+
pushRootInjectItem(items, {
|
|
1603
|
+
relativePath: target.relativePath,
|
|
1604
|
+
source,
|
|
1605
|
+
notes: `selected, updated Dflow block in existing ${target.relativePath}`,
|
|
1606
|
+
content: applyEol(updated, eol),
|
|
1607
|
+
expectedContent: existingContent
|
|
1608
|
+
});
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// No agent-shim marker below.
|
|
1613
|
+
|
|
1614
|
+
// Case 2a — the whole file is a shim Dflow itself would generate (a pristine
|
|
1615
|
+
// 0.8/0.9 shim, or an earlier whole-file injection): regenerate it (idempotent +
|
|
1616
|
+
// migrate an older template forward), preserving the file's dominant EOL. When this
|
|
1617
|
+
// run is not (re)generating a trigger, keep any trigger block the file already has.
|
|
1618
|
+
if (isPristineDflowAgentsShim(existingContent, baseShimBody, target.relativePath)) {
|
|
1619
|
+
let newWhole = fullShim;
|
|
1620
|
+
if (!wantsTrigger && isCodex) {
|
|
1621
|
+
const existingTrigger = extractCodexTriggerBlock(lf);
|
|
1622
|
+
if (existingTrigger) {
|
|
1623
|
+
newWhole = `${baseShimBody.replace(/\n+$/, '')}\n\n${existingTrigger.trim()}\n`;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
pushRootInjectItem(items, {
|
|
1627
|
+
relativePath: target.relativePath,
|
|
1628
|
+
source,
|
|
1629
|
+
notes: `selected, regenerated Dflow ${target.relativePath} shim`,
|
|
1630
|
+
content: applyEol(newWhole, eol),
|
|
1631
|
+
expectedContent: existingContent
|
|
1632
|
+
});
|
|
1633
|
+
return;
|
|
1325
1634
|
}
|
|
1326
1635
|
|
|
1636
|
+
// Case 2d — the file already references the guide but is neither pristine nor
|
|
1637
|
+
// marker-managed (a guide-configured file the user wrote / heavily edited). Keep
|
|
1638
|
+
// the base shim skipped so we never duplicate their guide pointer. Under Codex
|
|
1639
|
+
// --command-adapters still install / update the self-delimited trigger block (OQ#6c).
|
|
1640
|
+
if (contentReferencesAiAgentGuide(existingContent)) {
|
|
1641
|
+
if (wantsTrigger) {
|
|
1642
|
+
pushRootInjectItem(items, {
|
|
1643
|
+
relativePath: target.relativePath,
|
|
1644
|
+
source,
|
|
1645
|
+
notes: `selected, installed Dflow command triggers into existing ${target.relativePath}`,
|
|
1646
|
+
content: applyEol(upsertCodexTriggerBlock(lf, triggerBlock), eol),
|
|
1647
|
+
expectedContent: existingContent
|
|
1648
|
+
});
|
|
1649
|
+
} else {
|
|
1650
|
+
items.push({
|
|
1651
|
+
relativePath: target.relativePath,
|
|
1652
|
+
source,
|
|
1653
|
+
notes: `selected, ${target.relativePath} already points to AI-AGENT-GUIDE.md`,
|
|
1654
|
+
content: fullShim,
|
|
1655
|
+
action: 'skip',
|
|
1656
|
+
intentionalSkip: true,
|
|
1657
|
+
size: Buffer.byteLength(fullShim, 'utf8')
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Case 2e — user-owned, non-guide existing file: append the marked Dflow block(s)
|
|
1664
|
+
// at end of file (the core new behavior; replaces the old snippet-park). Append-only,
|
|
1665
|
+
// previewed, reversible (delete the block to revert), idempotent on re-run (case 2b).
|
|
1666
|
+
const blocks = wantsTrigger ? [agentShimBlock, triggerBlock] : [agentShimBlock];
|
|
1667
|
+
pushRootInjectItem(items, {
|
|
1668
|
+
relativePath: target.relativePath,
|
|
1669
|
+
source,
|
|
1670
|
+
notes: `selected, appended Dflow block to existing ${target.relativePath}`,
|
|
1671
|
+
content: appendDflowBlocks(existingContent, blocks, eol),
|
|
1672
|
+
expectedContent: existingContent
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Push one plan item that edits a user-owned root agent file. The write phase
|
|
1677
|
+
// (writeFilePlan rootInject branch) re-reads the file and requires raw-byte equality
|
|
1678
|
+
// with `expectedContent` before writing, so a file changed between preview and write
|
|
1679
|
+
// is never clobbered. When the computed content already equals the file, record a
|
|
1680
|
+
// quiet idempotent skip instead of a no-op write.
|
|
1681
|
+
function pushRootInjectItem(items, { relativePath, source, notes, content, expectedContent }) {
|
|
1682
|
+
const size = Buffer.byteLength(content, 'utf8');
|
|
1683
|
+
if (content === expectedContent) {
|
|
1684
|
+
items.push({
|
|
1685
|
+
relativePath,
|
|
1686
|
+
source,
|
|
1687
|
+
notes: `${notes}; already current`,
|
|
1688
|
+
content,
|
|
1689
|
+
action: 'skip',
|
|
1690
|
+
intentionalSkip: true,
|
|
1691
|
+
size
|
|
1692
|
+
});
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1327
1695
|
items.push({
|
|
1328
1696
|
relativePath,
|
|
1329
|
-
source
|
|
1697
|
+
source,
|
|
1330
1698
|
notes,
|
|
1331
1699
|
content,
|
|
1332
|
-
|
|
1700
|
+
expectedContent,
|
|
1701
|
+
action: 'update',
|
|
1702
|
+
overwrite: true,
|
|
1703
|
+
rootInject: true,
|
|
1704
|
+
size
|
|
1333
1705
|
});
|
|
1334
1706
|
}
|
|
1335
1707
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1708
|
+
function contentReferencesAiAgentGuide(content) {
|
|
1709
|
+
return content.includes('dflow/specs/shared/AI-AGENT-GUIDE.md') ||
|
|
1710
|
+
content.includes('dflow\\specs\\shared\\AI-AGENT-GUIDE.md');
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function wrapAgentShimBlock(shimBody) {
|
|
1714
|
+
const body = shimBody.replace(/\n+$/, '');
|
|
1715
|
+
return `${AGENT_SHIM_SECTION_START}\n${body}\n${AGENT_SHIM_SECTION_END}`;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// Append `block` (LF) at end of `lfContent` (LF) with a one-blank-line separator,
|
|
1719
|
+
// preserving the existing content and its final-newline convention exactly — trailing
|
|
1720
|
+
// whitespace is never stripped. Returns LF.
|
|
1721
|
+
function appendBlockLf(lfContent, block) {
|
|
1722
|
+
if (lfContent === '') {
|
|
1723
|
+
return `${block}\n`;
|
|
1724
|
+
}
|
|
1725
|
+
if (lfContent.endsWith('\n\n')) {
|
|
1726
|
+
return `${lfContent}${block}\n`;
|
|
1727
|
+
}
|
|
1728
|
+
if (lfContent.endsWith('\n')) {
|
|
1729
|
+
return `${lfContent}\n${block}\n`;
|
|
1343
1730
|
}
|
|
1731
|
+
return `${lfContent}\n\n${block}\n`;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Append Dflow blocks at end of an existing user file. The user's content is kept in
|
|
1735
|
+
// full (never stripped or reordered) and the final-newline convention is preserved —
|
|
1736
|
+
// only a one-blank-line separator is added. The whole result is emitted in the file's
|
|
1737
|
+
// dominant EOL (the approved EOL policy), so a pure LF / pure CRLF user prefix
|
|
1738
|
+
// round-trips byte-for-byte. `blocks` are LF strings.
|
|
1739
|
+
function appendDflowBlocks(existingContent, blocks, eol) {
|
|
1740
|
+
const lf = existingContent.replace(/\r\n/g, '\n');
|
|
1741
|
+
return applyEol(appendBlockLf(lf, blocks.join('\n\n')), eol);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Replace an existing well-formed Codex trigger block, or append one at EOF preserving
|
|
1745
|
+
// the existing content + final-newline convention (no stripping). Assumes the trigger
|
|
1746
|
+
// markers are absent or a single well-formed pair (malformed is handled upstream as a
|
|
1747
|
+
// snippet fallback). `lfContent` / `triggerBlock` are LF strings.
|
|
1748
|
+
function upsertCodexTriggerBlock(lfContent, triggerBlock) {
|
|
1749
|
+
const region = classifyMarkedRegion(lfContent, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
|
|
1750
|
+
if (region.state === 'present') {
|
|
1751
|
+
return lfContent.slice(0, region.startIdx) + triggerBlock + lfContent.slice(region.endIdx);
|
|
1752
|
+
}
|
|
1753
|
+
return appendBlockLf(lfContent, triggerBlock);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function extractCodexTriggerBlock(content) {
|
|
1757
|
+
const region = classifyMarkedRegion(content, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
|
|
1758
|
+
return region.state === 'present' ? content.slice(region.startIdx, region.endIdx) : null;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Classify a START/END marker pair in `content`:
|
|
1762
|
+
// 'absent' neither marker appears
|
|
1763
|
+
// 'present' exactly one START and one END, in order (startIdx/endIdx returned)
|
|
1764
|
+
// 'malformed' any other shape (partial / duplicated / nested / reversed)
|
|
1765
|
+
// The markers are single-line HTML comments, so classification is identical on raw or
|
|
1766
|
+
// LF-normalized content; callers slice on whichever string they passed in.
|
|
1767
|
+
function classifyMarkedRegion(content, startMarker, endMarker) {
|
|
1768
|
+
const startCount = countOccurrences(content, startMarker);
|
|
1769
|
+
const endCount = countOccurrences(content, endMarker);
|
|
1770
|
+
if (startCount === 0 && endCount === 0) {
|
|
1771
|
+
return { state: 'absent' };
|
|
1772
|
+
}
|
|
1773
|
+
if (startCount === 1 && endCount === 1) {
|
|
1774
|
+
const startIdx = content.indexOf(startMarker);
|
|
1775
|
+
const endInner = content.indexOf(endMarker);
|
|
1776
|
+
if (startIdx < endInner) {
|
|
1777
|
+
return { state: 'present', startIdx, endIdx: endInner + endMarker.length };
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
return { state: 'malformed' };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function countOccurrences(haystack, needle) {
|
|
1784
|
+
if (!needle) {
|
|
1785
|
+
return 0;
|
|
1786
|
+
}
|
|
1787
|
+
let count = 0;
|
|
1788
|
+
let index = haystack.indexOf(needle);
|
|
1789
|
+
while (index !== -1) {
|
|
1790
|
+
count += 1;
|
|
1791
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
1792
|
+
}
|
|
1793
|
+
return count;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function markerPositions(content, marker) {
|
|
1797
|
+
const positions = [];
|
|
1798
|
+
let index = content.indexOf(marker);
|
|
1799
|
+
while (index !== -1) {
|
|
1800
|
+
positions.push(index);
|
|
1801
|
+
index = content.indexOf(marker, index + marker.length);
|
|
1802
|
+
}
|
|
1803
|
+
return positions;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// True when Codex trigger markers cross the [start, end) boundary — at least one inside
|
|
1807
|
+
// and at least one outside. Slicing [start, end) (the case-2b agent-shim replace) is
|
|
1808
|
+
// then unsafe: removing the inside marker(s) can leave the outside marker(s) forming a
|
|
1809
|
+
// new well-formed trigger region wrapping the regenerated block. All-inside (removed
|
|
1810
|
+
// with the block) and all-outside (untouched) are both safe; only a boundary cross is.
|
|
1811
|
+
function codexTriggerMarkersStraddle(content, start, end) {
|
|
1812
|
+
const positions = [
|
|
1813
|
+
...markerPositions(content, CODEX_TRIGGER_SECTION_START),
|
|
1814
|
+
...markerPositions(content, CODEX_TRIGGER_SECTION_END)
|
|
1815
|
+
];
|
|
1816
|
+
const inside = positions.some((position) => position >= start && position < end);
|
|
1817
|
+
const outside = positions.some((position) => position < start || position >= end);
|
|
1818
|
+
return inside && outside;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Dominant line ending of a user file, so injected blocks match it (Windows projects
|
|
1822
|
+
// may be CRLF). The repo's own LF policy (.gitattributes) governs repo files only, not
|
|
1823
|
+
// an adopter's project files.
|
|
1824
|
+
function detectDominantEol(content) {
|
|
1825
|
+
const crlf = (content.match(/\r\n/g) || []).length;
|
|
1826
|
+
const lfOnly = (content.match(/\n/g) || []).length - crlf;
|
|
1827
|
+
return crlf > lfOnly ? '\r\n' : '\n';
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function applyEol(content, eol) {
|
|
1831
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
1832
|
+
return eol === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
|
|
1344
1833
|
}
|
|
1345
1834
|
|
|
1346
1835
|
function getAiAgentTarget(agent) {
|
|
@@ -1371,9 +1860,36 @@ function buildAiAgentShim(targetPath, commandRegistry = []) {
|
|
|
1371
1860
|
? buildCodexCommandTriggerSection(commandRegistry)
|
|
1372
1861
|
: '';
|
|
1373
1862
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1863
|
+
return `# ${title}
|
|
1864
|
+
|
|
1865
|
+
This project uses Dflow for spec-first AI-assisted development.
|
|
1866
|
+
|
|
1867
|
+
For spec-impacting work — a new feature, a change to product, user-facing, or
|
|
1868
|
+
domain behavior, a new requirement, or a bug-fix workflow — read and follow:
|
|
1869
|
+
|
|
1870
|
+
- \`dflow/specs/shared/AI-AGENT-GUIDE.md\` — command registry, routing rules, and project context.
|
|
1871
|
+
- \`dflow/specs/shared/dflow-workflows/\` — vendored workflow bundle with executable step definitions.
|
|
1872
|
+
|
|
1873
|
+
For routine work (refactors, renames, chores, formatting, dependency bumps, or
|
|
1874
|
+
general code questions), proceed normally; you need not read the guide first.
|
|
1875
|
+
|
|
1876
|
+
Keep tool-specific instruction files small. The guide and workflow bundle are
|
|
1877
|
+
the authoritative sources for Dflow workflow rules, slash-command behavior,
|
|
1878
|
+
spec locations, and SDD/DDD constraints.${commandTriggerHint}
|
|
1879
|
+
`;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// Frozen pre-scoping shim body (the wording shipped through v0.9.0 and the
|
|
1883
|
+
// Phase-2 @import-removal interim: "Before planning or editing code ..."). Used
|
|
1884
|
+
// ONLY by isPristineDflowAgentsShim so an adopter's older whole-file shim is
|
|
1885
|
+
// still recognized as Dflow-generated and regenerated to the current scoped
|
|
1886
|
+
// wording. Changing buildAiAgentShim's body without updating this matcher would
|
|
1887
|
+
// strand old shims on the guide-reference skip path — they would keep the old
|
|
1888
|
+
// body, and for CLAUDE.md the legacy @import.
|
|
1889
|
+
function buildLegacyAgentShimBody(targetPath) {
|
|
1890
|
+
const title = targetPath === '.github/copilot-instructions.md'
|
|
1891
|
+
? 'GitHub Copilot Repository Instructions'
|
|
1892
|
+
: `${targetPath} - Dflow Project Instructions`;
|
|
1377
1893
|
|
|
1378
1894
|
return `# ${title}
|
|
1379
1895
|
|
|
@@ -1386,7 +1902,7 @@ Before planning or editing code, read and follow:
|
|
|
1386
1902
|
|
|
1387
1903
|
Keep tool-specific instruction files small. The guide and workflow bundle are
|
|
1388
1904
|
the authoritative sources for Dflow workflow rules, slash-command behavior,
|
|
1389
|
-
spec locations, and SDD/DDD constraints
|
|
1905
|
+
spec locations, and SDD/DDD constraints.
|
|
1390
1906
|
`;
|
|
1391
1907
|
}
|
|
1392
1908
|
|
|
@@ -1469,52 +1985,84 @@ async function buildDflowSkillAdapter() {
|
|
|
1469
1985
|
return fs.readFile(sourcePath, 'utf8');
|
|
1470
1986
|
}
|
|
1471
1987
|
|
|
1988
|
+
// Project-level skill paths per AI agent. Claude, Codex (the `agents` key,
|
|
1989
|
+
// labeled "Codex / Copilot coding agent"), and GitHub Copilot each get the SAME
|
|
1990
|
+
// edition-neutral thin skill projected to their own canonical skill path.
|
|
1991
|
+
// PROPOSAL-056 generalized this; the Copilot native projection (`.github/skills`)
|
|
1992
|
+
// was un-deferred after a spike confirmed Copilot discovers and auto-triggers a
|
|
1993
|
+
// skill from its own path with the cross-read `.claude`/`.agents` paths removed.
|
|
1994
|
+
// Note: Copilot also cross-reads `.claude/skills` and `.agents/skills`, so a
|
|
1995
|
+
// project that selects Copilot alongside Claude/Codex may surface the same
|
|
1996
|
+
// `dflow` skill from more than one path. The copies Dflow *generates* are
|
|
1997
|
+
// byte-identical (same name, body, and marker), so duplicate generated copies
|
|
1998
|
+
// carry identical behavior — but this duplicate-discovery case is not spiked,
|
|
1999
|
+
// and a pre-existing non-Dflow skill at a cross-read path is left unchanged by
|
|
2000
|
+
// the overwrite guard below and could differ. Remove/rename such a file to
|
|
2001
|
+
// avoid a divergent same-name duplicate.
|
|
2002
|
+
const SKILL_ADAPTER_TARGETS = {
|
|
2003
|
+
claude: { relativePath: '.claude/skills/dflow/SKILL.md', source: 'generated:claude-skill-adapter' },
|
|
2004
|
+
agents: { relativePath: '.agents/skills/dflow/SKILL.md', source: 'generated:agents-skill-adapter' },
|
|
2005
|
+
copilot: { relativePath: '.github/skills/dflow/SKILL.md', source: 'generated:copilot-skill-adapter' }
|
|
2006
|
+
};
|
|
2007
|
+
|
|
1472
2008
|
async function addSkillAdapterItems(cwd, items, aiAgents, skills, warnings) {
|
|
1473
2009
|
if (!skills) {
|
|
1474
2010
|
return;
|
|
1475
2011
|
}
|
|
1476
2012
|
|
|
1477
|
-
|
|
2013
|
+
const skillTargets = aiAgents
|
|
2014
|
+
.filter((agent) => SKILL_ADAPTER_TARGETS[agent])
|
|
2015
|
+
.map((agent) => SKILL_ADAPTER_TARGETS[agent]);
|
|
2016
|
+
|
|
2017
|
+
if (skillTargets.length === 0) {
|
|
2018
|
+
// No skill-capable agent was selected at all. Nothing to project.
|
|
1478
2019
|
warnings.push(
|
|
1479
|
-
'The --skills flag
|
|
2020
|
+
'The --skills flag projects a project-level skill for Claude Code, Codex, and GitHub Copilot; no skill adapter was generated because none was a selected target.'
|
|
1480
2021
|
);
|
|
1481
2022
|
return;
|
|
1482
2023
|
}
|
|
1483
2024
|
|
|
1484
|
-
const relativePath = '.claude/skills/dflow/SKILL.md';
|
|
1485
|
-
const targetPath = path.join(cwd, relativePath);
|
|
1486
|
-
|
|
1487
2025
|
// The thin skill is edition-neutral (it only points to the per-edition guide),
|
|
1488
2026
|
// so there is nothing edition-specific to go stale — re-running just rewrites
|
|
1489
2027
|
// the same marker-guarded file (idempotent). No LEGACY skill set exists yet
|
|
1490
2028
|
// (skills are new in PROPOSAL-038); future skill cleanup would extend the same
|
|
1491
2029
|
// LEGACY_* / addLegacyCommandAdapterCleanupItems marker-fingerprint pattern.
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
2030
|
+
const skillContent = await buildDflowSkillAdapter();
|
|
2031
|
+
|
|
2032
|
+
for (const target of skillTargets) {
|
|
2033
|
+
const targetPath = path.join(cwd, target.relativePath);
|
|
2034
|
+
|
|
2035
|
+
let existingContent;
|
|
2036
|
+
try {
|
|
2037
|
+
existingContent = await fs.readFile(targetPath, 'utf8');
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
if (error.code !== 'ENOENT') {
|
|
2040
|
+
throw error;
|
|
2041
|
+
}
|
|
2042
|
+
existingContent = undefined;
|
|
1498
2043
|
}
|
|
1499
|
-
existingContent = undefined;
|
|
1500
|
-
}
|
|
1501
2044
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
2045
|
+
if (existingContent !== undefined && !existingContent.includes(SKILL_ADAPTER_GENERATED_MARKER)) {
|
|
2046
|
+
warnings.push(
|
|
2047
|
+
`Existing ${target.relativePath} is not a Dflow-generated skill; left unchanged. Remove or rename it to let Dflow manage this skill.`
|
|
2048
|
+
);
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
1508
2051
|
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
2052
|
+
// addSkillAdapterItems runs AFTER finalizePlanItems, so these items never pass
|
|
2053
|
+
// through that pass — set `action` explicitly here (the preview table and the
|
|
2054
|
+
// result report read item.action directly). `overwrite: true` keeps the write
|
|
2055
|
+
// phase rewriting an existing marker-stamped skill.
|
|
2056
|
+
items.push({
|
|
2057
|
+
relativePath: target.relativePath,
|
|
2058
|
+
source: target.source,
|
|
2059
|
+
notes: 'skill adapter, thin skill pointing to AI-AGENT-GUIDE.md',
|
|
2060
|
+
content: skillContent,
|
|
2061
|
+
size: Buffer.byteLength(skillContent, 'utf8'),
|
|
2062
|
+
overwrite: true,
|
|
2063
|
+
action: existingContent === undefined ? 'create' : 'update'
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
1518
2066
|
}
|
|
1519
2067
|
|
|
1520
2068
|
function buildLegacyCommandAdapterFingerprint(legacy, command) {
|
|
@@ -1544,6 +2092,63 @@ function normalizeCommandAdapterFingerprint(content) {
|
|
|
1544
2092
|
return String(content).replace(/\r\n/g, '\n');
|
|
1545
2093
|
}
|
|
1546
2094
|
|
|
2095
|
+
function normalizeShimForMatch(content) {
|
|
2096
|
+
return String(content)
|
|
2097
|
+
.replace(/\r\n/g, '\n')
|
|
2098
|
+
.split('\n')
|
|
2099
|
+
.map((line) => line.replace(/[ \t]+$/, ''))
|
|
2100
|
+
.join('\n')
|
|
2101
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
2102
|
+
.trim();
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function stripCodexTriggerBlock(content) {
|
|
2106
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2107
|
+
const re = new RegExp(
|
|
2108
|
+
`\\n*${escape(CODEX_TRIGGER_SECTION_START)}[\\s\\S]*?${escape(CODEX_TRIGGER_SECTION_END)}\\n*`
|
|
2109
|
+
);
|
|
2110
|
+
return content.replace(re, '\n');
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Removes the pre-Phase-2 Markdown `@import` block that old CLAUDE.md shims
|
|
2114
|
+
// appended after the shim body. Operates on already-normalized content (LF, no
|
|
2115
|
+
// trailing whitespace, blank runs collapsed, trimmed — see normalizeShimForMatch),
|
|
2116
|
+
// so editor-added trailing spaces, CRLF, or an extra blank line in an old file
|
|
2117
|
+
// do not defeat the match.
|
|
2118
|
+
function stripLegacyImportSuffix(normalizedContent) {
|
|
2119
|
+
return normalizedContent.replace(
|
|
2120
|
+
/\n+If your tool supports Markdown imports, the canonical guide is imported below:\n+@dflow\/specs\/shared\/AI-AGENT-GUIDE\.md$/,
|
|
2121
|
+
''
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// A CLAUDE.md / AGENTS.md is a safely-injectable Dflow shim when, after removing
|
|
2126
|
+
// any previously-injected trigger block, it matches the shim Dflow itself
|
|
2127
|
+
// generates. This covers a pristine 0.8.0/0.9.0 shim (no marker, normalized
|
|
2128
|
+
// template match) and a shim Dflow already injected into (idempotent
|
|
2129
|
+
// re-projection). For CLAUDE.md ONLY, a pre-Phase-2 shim that still carries the
|
|
2130
|
+
// legacy `@import` also counts and is regenerated WITHOUT it, so the
|
|
2131
|
+
// progressive-disclosure fix reaches existing projects. The import was only ever
|
|
2132
|
+
// generated into CLAUDE.md, so scoping the strip there avoids clobbering a
|
|
2133
|
+
// user-added import block in a hand-edited AGENTS.md / Copilot shim. A
|
|
2134
|
+
// user-edited shim otherwise fails the match and degrades to a snippet.
|
|
2135
|
+
function isPristineDflowAgentsShim(existingContent, baseShim, relativePath) {
|
|
2136
|
+
const target = normalizeShimForMatch(baseShim);
|
|
2137
|
+
let existing = normalizeShimForMatch(stripCodexTriggerBlock(existingContent));
|
|
2138
|
+
if (relativePath === 'CLAUDE.md') {
|
|
2139
|
+
existing = stripLegacyImportSuffix(existing);
|
|
2140
|
+
}
|
|
2141
|
+
if (existing === target) {
|
|
2142
|
+
return true;
|
|
2143
|
+
}
|
|
2144
|
+
// Back-compat: older Dflow shims (v0.9.0 / Phase-2 interim) used different body
|
|
2145
|
+
// wording ("Before planning or editing code ..."). Recognize that frozen
|
|
2146
|
+
// wording as pristine so configure-agents regenerates it to the current scoped
|
|
2147
|
+
// wording (and, for CLAUDE.md, drops the legacy @import already stripped above).
|
|
2148
|
+
// Without this, the body reword would strand old shims on the skip path.
|
|
2149
|
+
return existing === normalizeShimForMatch(buildLegacyAgentShimBody(relativePath));
|
|
2150
|
+
}
|
|
2151
|
+
|
|
1547
2152
|
function buildCodexCommandTriggerSection(commandRegistry) {
|
|
1548
2153
|
const triggers = commandRegistry
|
|
1549
2154
|
.map((command) => {
|
|
@@ -1554,6 +2159,8 @@ function buildCodexCommandTriggerSection(commandRegistry) {
|
|
|
1554
2159
|
|
|
1555
2160
|
return `
|
|
1556
2161
|
|
|
2162
|
+
${CODEX_TRIGGER_SECTION_START}
|
|
2163
|
+
|
|
1557
2164
|
## Dflow Text Triggers
|
|
1558
2165
|
|
|
1559
2166
|
Codex does not install Dflow command files. When the developer asks for a
|
|
@@ -1568,6 +2175,8 @@ and execute it.
|
|
|
1568
2175
|
Recognized canonical triggers:
|
|
1569
2176
|
|
|
1570
2177
|
${triggers}
|
|
2178
|
+
|
|
2179
|
+
${CODEX_TRIGGER_SECTION_END}
|
|
1571
2180
|
`;
|
|
1572
2181
|
}
|
|
1573
2182
|
|
|
@@ -1757,8 +2366,7 @@ const PLACEHOLDER_ALIASES = {
|
|
|
1757
2366
|
|
|
1758
2367
|
function buildSubstitutionMap(cwd, answers) {
|
|
1759
2368
|
const extracted = extractTechStackPlaceholders(answers.techStackSummary);
|
|
1760
|
-
const
|
|
1761
|
-
const gitStyle = gitSelection.length === 1 ? (gitSelection[0] === 'git-trunk' ? 'trunk' : 'gitflow') : null;
|
|
2369
|
+
const gitStyle = answers.gitPolicy === 'gitflow' ? 'gitflow' : (answers.gitPolicy === 'trunk' ? 'trunk' : null);
|
|
1762
2370
|
const systemName = path.basename(cwd);
|
|
1763
2371
|
|
|
1764
2372
|
const map = new Map([
|
|
@@ -1990,6 +2598,70 @@ function stripProseLanguageSections(content) {
|
|
|
1990
2598
|
return kept.join('\n');
|
|
1991
2599
|
}
|
|
1992
2600
|
|
|
2601
|
+
function stripNamedSections(content, headings) {
|
|
2602
|
+
const set = new Set(headings.map((heading) => `## ${heading}`));
|
|
2603
|
+
const kept = [];
|
|
2604
|
+
let skipping = false;
|
|
2605
|
+
|
|
2606
|
+
for (const line of content.split(/\r?\n/)) {
|
|
2607
|
+
if (set.has(line.trim())) {
|
|
2608
|
+
skipping = true;
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
if (skipping && /^## /.test(line)) {
|
|
2612
|
+
skipping = false;
|
|
2613
|
+
}
|
|
2614
|
+
if (!skipping) {
|
|
2615
|
+
kept.push(line);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
return kept.join('\n');
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function buildGitPolicySection(gitPolicy) {
|
|
2623
|
+
const policy = gitPolicy === 'gitflow' ? 'gitflow' : 'trunk';
|
|
2624
|
+
return `## Git Policy
|
|
2625
|
+
|
|
2626
|
+
Selected Git policy: \`${policy}\`
|
|
2627
|
+
|
|
2628
|
+
Dflow runtime branch gates and finish-feature guidance follow this policy. Both
|
|
2629
|
+
policies use feature branches; the policy selects the finish-stage merge
|
|
2630
|
+
guidance — \`gitflow\` introduces merge-commit / release+develop flow, while
|
|
2631
|
+
\`trunk\` (GitHub Flow) favors squash or fast-forward back to the main branch
|
|
2632
|
+
with small, frequent merges.`;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
function buildAiCommitPolicySection(aiCommitMarker) {
|
|
2636
|
+
const marker = ['none', 'co-authored-by', 'prefix'].includes(aiCommitMarker) ? aiCommitMarker : 'none';
|
|
2637
|
+
return `## AI Commit Policy
|
|
2638
|
+
|
|
2639
|
+
AI commit marker: \`${marker}\`
|
|
2640
|
+
|
|
2641
|
+
At lifecycle checkpoints the AI may offer to commit using your Git identity; you
|
|
2642
|
+
can always decline (Y / N). Completed and skipped checkpoints are recorded in
|
|
2643
|
+
each feature's Checkpoint Log. Marker modes:
|
|
2644
|
+
|
|
2645
|
+
- \`none\`: AI-made commits carry no extra marker.
|
|
2646
|
+
- \`co-authored-by\`: append a \`Co-Authored-By: dflow-ai <noreply@dflow.local>\`
|
|
2647
|
+
trailer (teams may customize the name/email).
|
|
2648
|
+
- \`prefix\`: prefix the commit subject with \`[ai-assisted]\`.`;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
function ensureConventionPolicySections(content, answers) {
|
|
2652
|
+
const stripped = stripNamedSections(content, ['Git Policy', 'AI Commit Policy']).replace(/\n{3,}/g, '\n\n');
|
|
2653
|
+
const sections = `${buildGitPolicySection(answers.gitPolicy)}\n\n${buildAiCommitPolicySection(answers.aiCommitMarker)}`;
|
|
2654
|
+
const markerMatch = stripped.match(/^## Filling the Templates/m);
|
|
2655
|
+
|
|
2656
|
+
if (markerMatch && typeof markerMatch.index === 'number') {
|
|
2657
|
+
const before = stripped.slice(0, markerMatch.index).replace(/\s*$/, '\n\n');
|
|
2658
|
+
const after = stripped.slice(markerMatch.index).replace(/^\s*/, '');
|
|
2659
|
+
return `${before}${sections}\n\n${after}`;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
return `${stripped.replace(/\s*$/, '\n\n')}${sections}\n`;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
1993
2665
|
function buildProseLanguageSection(proseLanguage) {
|
|
1994
2666
|
return `## Prose Language
|
|
1995
2667
|
|
|
@@ -2058,7 +2730,7 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2058
2730
|
} catch (error) {
|
|
2059
2731
|
if (error.code === 'ENOENT') {
|
|
2060
2732
|
result.skipped.push(item.relativePath);
|
|
2061
|
-
result.warnings.push(`Skipped missing stale
|
|
2733
|
+
result.warnings.push(`Skipped missing stale file: ${item.relativePath}`);
|
|
2062
2734
|
continue;
|
|
2063
2735
|
}
|
|
2064
2736
|
throw error;
|
|
@@ -2066,14 +2738,14 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2066
2738
|
|
|
2067
2739
|
if (!stats.isFile()) {
|
|
2068
2740
|
result.skipped.push(item.relativePath);
|
|
2069
|
-
result.warnings.push(`Skipped stale
|
|
2741
|
+
result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
|
|
2070
2742
|
continue;
|
|
2071
2743
|
}
|
|
2072
2744
|
|
|
2073
2745
|
const currentContent = await fs.readFile(targetPath, 'utf8');
|
|
2074
2746
|
if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
|
|
2075
2747
|
result.skipped.push(item.relativePath);
|
|
2076
|
-
result.warnings.push(`Skipped stale
|
|
2748
|
+
result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
|
|
2077
2749
|
continue;
|
|
2078
2750
|
}
|
|
2079
2751
|
|
|
@@ -2082,6 +2754,45 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2082
2754
|
continue;
|
|
2083
2755
|
}
|
|
2084
2756
|
|
|
2757
|
+
// PROPOSAL-054: a user-owned root agent file edit (append / replace / regenerate).
|
|
2758
|
+
// Re-read and require RAW-byte equality with the previewed content before writing,
|
|
2759
|
+
// so a file the user changed (or deleted, or replaced with a non-file) between the
|
|
2760
|
+
// preview and the write is never clobbered — skip + warn + ask them to re-run. Raw
|
|
2761
|
+
// equality is intentionally stricter than the normalized compare used for stale
|
|
2762
|
+
// removal: any whitespace / EOL change counts as "changed after preview".
|
|
2763
|
+
if (item.rootInject) {
|
|
2764
|
+
let stats;
|
|
2765
|
+
try {
|
|
2766
|
+
stats = await fs.stat(targetPath);
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
if (error.code === 'ENOENT') {
|
|
2769
|
+
result.skipped.push(item.relativePath);
|
|
2770
|
+
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} no longer exists; re-run to inject the Dflow block.`);
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
throw error;
|
|
2774
|
+
}
|
|
2775
|
+
if (!stats.isFile()) {
|
|
2776
|
+
result.skipped.push(item.relativePath);
|
|
2777
|
+
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} is no longer a regular file; re-run to inject the Dflow block.`);
|
|
2778
|
+
continue;
|
|
2779
|
+
}
|
|
2780
|
+
const currentRaw = await fs.readFile(targetPath, 'utf8');
|
|
2781
|
+
if (currentRaw !== item.expectedContent) {
|
|
2782
|
+
result.skipped.push(item.relativePath);
|
|
2783
|
+
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} changed after the preview; re-run to inject the Dflow block.`);
|
|
2784
|
+
continue;
|
|
2785
|
+
}
|
|
2786
|
+
if (item.content === currentRaw) {
|
|
2787
|
+
result.skipped.push(item.relativePath);
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
2791
|
+
await fs.writeFile(targetPath, item.content);
|
|
2792
|
+
result.updated.push(item.relativePath);
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2085
2796
|
if (await pathExists(targetPath)) {
|
|
2086
2797
|
if (item.overwrite) {
|
|
2087
2798
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
@@ -2091,7 +2802,12 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2091
2802
|
}
|
|
2092
2803
|
|
|
2093
2804
|
result.skipped.push(item.relativePath);
|
|
2094
|
-
|
|
2805
|
+
// PROPOSAL-054: an intentional skip (an already-configured agent file, or an
|
|
2806
|
+
// already-current Dflow block) is expected, not a problem — don't emit the
|
|
2807
|
+
// generic "skipped existing target" warning for it.
|
|
2808
|
+
if (!item.intentionalSkip) {
|
|
2809
|
+
result.warnings.push(`Skipped existing target: ${item.relativePath}`);
|
|
2810
|
+
}
|
|
2095
2811
|
if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
|
|
2096
2812
|
result.warnings.push(
|
|
2097
2813
|
'Prose language was not written because dflow/specs/shared/_conventions.md already exists. Ensure it contains exactly one ## Prose Language section before running prose-generating flows.'
|
|
@@ -2100,6 +2816,15 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2100
2816
|
continue;
|
|
2101
2817
|
}
|
|
2102
2818
|
|
|
2819
|
+
// PROPOSAL-054: a plan item previewed as "skip" (an already-configured agent
|
|
2820
|
+
// file, or an already-current Dflow block) must never write. If its target
|
|
2821
|
+
// vanished between preview and write, do nothing — do NOT silently create a
|
|
2822
|
+
// file the preview said would be left alone.
|
|
2823
|
+
if (item.action === 'skip') {
|
|
2824
|
+
result.skipped.push(item.relativePath);
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2103
2828
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
2104
2829
|
await fs.writeFile(targetPath, item.content, { flag: 'wx' });
|
|
2105
2830
|
|
|
@@ -2214,20 +2939,25 @@ Recommended next steps:
|
|
|
2214
2939
|
`);
|
|
2215
2940
|
}
|
|
2216
2941
|
|
|
2217
|
-
function printConfigureAgentsNextSteps(stdout, commandAdapters = false) {
|
|
2942
|
+
function printConfigureAgentsNextSteps(stdout, commandAdapters = false, snippetFallback = false) {
|
|
2218
2943
|
const commandAdapterStep = commandAdapters
|
|
2219
2944
|
? '- 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'
|
|
2220
2945
|
: '';
|
|
2221
2946
|
|
|
2947
|
+
// PROPOSAL-054: Dflow now auto-injects the marker-delimited block into existing
|
|
2948
|
+
// agent files, so the merge-snippet step is shown only when a genuine-conflict
|
|
2949
|
+
// fallback snippet was actually written this run.
|
|
2950
|
+
const snippetStep = snippetFallback
|
|
2951
|
+
? '- 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'
|
|
2952
|
+
: '';
|
|
2953
|
+
|
|
2222
2954
|
stdout.write(`
|
|
2223
2955
|
Dflow AI agent configuration complete.
|
|
2224
2956
|
|
|
2225
2957
|
Recommended next steps:
|
|
2226
2958
|
- Keep AI-agent-specific root files small.
|
|
2227
2959
|
- Put durable workflow changes in dflow/specs/shared/AI-AGENT-GUIDE.md.
|
|
2228
|
-
|
|
2229
|
-
${commandAdapterStep}
|
|
2230
|
-
`);
|
|
2960
|
+
${snippetStep}${commandAdapterStep}`);
|
|
2231
2961
|
}
|
|
2232
2962
|
|
|
2233
2963
|
function printList(stdout, values) {
|
|
@@ -2329,6 +3059,7 @@ async function runDoctor(options = {}) {
|
|
|
2329
3059
|
await checkLegacyRootSpecsDir(cwd, findings);
|
|
2330
3060
|
await checkLegacySharedDir(cwd, findings);
|
|
2331
3061
|
await checkConventionsDflowVersion(cwd, findings);
|
|
3062
|
+
await checkOrphanedWorkflowBundleFiles(cwd, findings);
|
|
2332
3063
|
|
|
2333
3064
|
printDoctorReport(stdout, cwd, findings);
|
|
2334
3065
|
return 0;
|
|
@@ -2386,6 +3117,69 @@ async function checkConventionsDflowVersion(cwd, findings) {
|
|
|
2386
3117
|
}
|
|
2387
3118
|
}
|
|
2388
3119
|
|
|
3120
|
+
// PROPOSAL-052 (c): read-only mop-up for the manifest-orphan edge. A
|
|
3121
|
+
// Dflow-generated bundle file that is no longer in the current package source
|
|
3122
|
+
// can linger if it was retired before generalized stale-removal shipped, or the
|
|
3123
|
+
// project was projected from a pre-release / non-registry source whose manifest
|
|
3124
|
+
// later forgot it. configure-agents only auto-removes files the manifest still
|
|
3125
|
+
// lists; a manifest-orphaned file (the manifest no longer lists it) must be
|
|
3126
|
+
// deleted by hand. Doctor detects and reports such files read-only (never
|
|
3127
|
+
// deletes). Detection requires a directory scan because, by definition, the
|
|
3128
|
+
// manifest no longer lists the orphan — but a read-only scan is safe here.
|
|
3129
|
+
async function checkOrphanedWorkflowBundleFiles(cwd, findings) {
|
|
3130
|
+
const bundleDir = path.join(cwd, WORKFLOW_BUNDLE_DEST);
|
|
3131
|
+
if (!(await pathExists(bundleDir))) return;
|
|
3132
|
+
|
|
3133
|
+
const edition = await inferProjectBundleEdition(cwd);
|
|
3134
|
+
if (!edition) return;
|
|
3135
|
+
|
|
3136
|
+
let sourceFiles;
|
|
3137
|
+
try {
|
|
3138
|
+
sourceFiles = await listBundleSourceFiles(edition);
|
|
3139
|
+
} catch {
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
const sourceRel = new Set(sourceFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
|
|
3143
|
+
|
|
3144
|
+
for (const dir of ['references', 'templates']) {
|
|
3145
|
+
const projectedDir = path.join(bundleDir, dir);
|
|
3146
|
+
let entries;
|
|
3147
|
+
try {
|
|
3148
|
+
entries = await fs.readdir(projectedDir);
|
|
3149
|
+
} catch {
|
|
3150
|
+
continue;
|
|
3151
|
+
}
|
|
3152
|
+
for (const entry of entries) {
|
|
3153
|
+
const rel = `${WORKFLOW_BUNDLE_DEST}/${dir}/${entry}`;
|
|
3154
|
+
if (sourceRel.has(rel)) continue;
|
|
3155
|
+
const abs = path.join(projectedDir, entry);
|
|
3156
|
+
const fileStat = await fs.stat(abs).catch(() => null);
|
|
3157
|
+
if (!fileStat || !fileStat.isFile()) continue;
|
|
3158
|
+
const content = await fs.readFile(abs, 'utf8').catch(() => '');
|
|
3159
|
+
if (!content.includes(WORKFLOW_BUNDLE_GENERATED_MARKER)) continue;
|
|
3160
|
+
findings.push({
|
|
3161
|
+
level: 'info',
|
|
3162
|
+
title: `Retired workflow bundle file: ${rel}`,
|
|
3163
|
+
detail: 'A Dflow-generated bundle file that is no longer part of the package source for this edition (a retired file left behind).',
|
|
3164
|
+
action: 'Delete it manually to remove it. (Re-running `dflow configure-agents` only auto-removes files the manifest still lists; a manifest-orphaned file must be deleted by hand.)'
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
// Infers the project's bundle edition for read-only checks: prefer the manifest
|
|
3171
|
+
// (authoritative for what was projected), fall back to project structure.
|
|
3172
|
+
async function inferProjectBundleEdition(cwd) {
|
|
3173
|
+
const manifestResult = await readCurrentBundleManifest(cwd);
|
|
3174
|
+
if (
|
|
3175
|
+
manifestResult.kind === 'ok' &&
|
|
3176
|
+
(manifestResult.manifest.edition === 'greenfield' || manifestResult.manifest.edition === 'brownfield')
|
|
3177
|
+
) {
|
|
3178
|
+
return manifestResult.manifest.edition;
|
|
3179
|
+
}
|
|
3180
|
+
return inferExistingEdition(cwd);
|
|
3181
|
+
}
|
|
3182
|
+
|
|
2389
3183
|
function printDoctorReport(stdout, cwd, findings) {
|
|
2390
3184
|
stdout.write(`Dflow Doctor ${pkg.version}\n`);
|
|
2391
3185
|
stdout.write(`Project: ${cwd}\n\n`);
|
|
@@ -2413,5 +3207,9 @@ module.exports = {
|
|
|
2413
3207
|
runInit,
|
|
2414
3208
|
validateProseLanguage,
|
|
2415
3209
|
ensureProseLanguageSection,
|
|
2416
|
-
buildFilePlan
|
|
3210
|
+
buildFilePlan,
|
|
3211
|
+
// Exported for tests: the write phase enforces the PROPOSAL-054 raw-equality guard
|
|
3212
|
+
// for user-owned root agent files (changed-after-preview -> skip), which cannot be
|
|
3213
|
+
// exercised through the CLI because preview and write happen in one process.
|
|
3214
|
+
writeFilePlan
|
|
2417
3215
|
};
|