dflow-sdd-ddd 0.9.0 → 0.11.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 +96 -0
- package/README.en.md +73 -48
- package/README.md +46 -36
- package/TEMPLATE-COVERAGE.md +0 -1
- package/TEMPLATE-LANGUAGE-GLOSSARY.md +1 -0
- package/bin/dflow.js +7 -11
- package/docs/evaluating-dflow.en.md +11 -7
- package/docs/evaluating-dflow.md +9 -4
- 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 +125 -42
- package/docs/using-with-codex.md +93 -34
- package/docs/using-with-github-copilot.en.md +135 -34
- package/docs/using-with-github-copilot.md +120 -43
- package/docs/why-dflow.en.md +72 -0
- package/docs/why-dflow.md +72 -0
- package/lib/init.js +867 -214
- package/package.json +2 -2
- package/templates/brownfield/references/drift-verification.md +41 -10
- package/templates/brownfield/references/finish-feature-flow.md +3 -2
- package/templates/brownfield/references/git-integration.md +0 -1
- package/templates/brownfield/references/init-project-flow.md +31 -17
- package/templates/brownfield/references/modify-existing-flow.md +44 -38
- package/templates/brownfield/references/new-feature-flow.md +41 -11
- package/templates/brownfield/references/new-phase-flow.md +9 -2
- package/templates/brownfield/references/pr-review-checklist.md +7 -1
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +258 -29
- package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +5 -8
- package/templates/brownfield/scaffolding/_conventions.md +10 -9
- package/templates/brownfield/templates/_index.md +1 -1
- package/templates/brownfield/templates/context-map.md +12 -4
- package/templates/brownfield/templates/lightweight-spec.md +1 -1
- package/templates/brownfield/templates/phase-spec.md +1 -1
- package/templates/common/references/ddd-modeling-guide.md +643 -0
- package/templates/common/skill/SKILL.md +9 -6
- package/templates/greenfield/references/drift-verification.md +60 -15
- package/templates/greenfield/references/finish-feature-flow.md +3 -2
- package/templates/greenfield/references/git-integration.md +0 -1
- package/templates/greenfield/references/init-project-flow.md +31 -17
- package/templates/greenfield/references/modify-existing-flow.md +5 -7
- package/templates/greenfield/references/new-feature-flow.md +49 -19
- package/templates/greenfield/references/new-phase-flow.md +5 -2
- package/templates/greenfield/references/pr-review-checklist.md +9 -1
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +221 -29
- package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +10 -15
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +1 -1
- package/templates/greenfield/scaffolding/_conventions.md +9 -8
- package/templates/greenfield/templates/_index.md +1 -1
- package/templates/greenfield/templates/aggregate-design.md +6 -0
- package/templates/greenfield/templates/context-map.md +13 -4
- package/templates/greenfield/templates/events.md +4 -1
- package/templates/greenfield/templates/lightweight-spec.md +1 -1
- package/templates/greenfield/templates/phase-spec.md +1 -1
- package/docs/migrating-to-dflow-v1.md +0 -230
- package/templates/brownfield/templates/CLAUDE.md +0 -165
- package/templates/greenfield/references/ddd-modeling-guide.md +0 -351
- package/templates/greenfield/templates/CLAUDE.md +0 -172
package/lib/init.js
CHANGED
|
@@ -15,9 +15,24 @@ const SKILL_ADAPTER_GENERATED_MARKER = '<!-- dflow-generated: skill-adapter -->'
|
|
|
15
15
|
const WORKFLOW_BUNDLE_GENERATED_MARKER = '<!-- dflow-generated: workflow-bundle -->';
|
|
16
16
|
const CODEX_TRIGGER_SECTION_START = '<!-- dflow-generated: codex-command-triggers START -->';
|
|
17
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 -->';
|
|
18
26
|
const WORKFLOW_BUNDLE_DEST = 'dflow/specs/shared/dflow-workflows';
|
|
19
27
|
const WORKFLOW_BUNDLE_MANIFEST_PATH = `${WORKFLOW_BUNDLE_DEST}/.dflow-bundle-manifest.json`;
|
|
20
28
|
const COMMON_SKILL_SOURCE_REL = 'common/skill/SKILL.md';
|
|
29
|
+
// Files the common bundle tree (templates/common/) MUST provide. A missing
|
|
30
|
+
// common file is a broken package, NOT a retired bundle file: without this guard
|
|
31
|
+
// listBundleSourceFiles would return a smaller-but-"valid" set lacking the file,
|
|
32
|
+
// and configure-agents stale-removal would then DELETE the already-installed
|
|
33
|
+
// copy from the user's project (it diffs as "retired"). PROPOSAL-064 fresh-gate
|
|
34
|
+
// finding. Guarded before any stale cleanup / manifest write.
|
|
35
|
+
const REQUIRED_COMMON_BUNDLE_FILES = ['references/ddd-modeling-guide.md'];
|
|
21
36
|
const EXPECTED_COMMAND_IDS = [
|
|
22
37
|
'new-feature',
|
|
23
38
|
'modify-existing',
|
|
@@ -236,7 +251,7 @@ async function runInit(options = {}) {
|
|
|
236
251
|
const detection = await detectProjectSignals(cwd);
|
|
237
252
|
const answers = await promptForAnswers(rl, stdout, stderr, detection);
|
|
238
253
|
const plan = await buildFilePlan(cwd, answers);
|
|
239
|
-
const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.bundleWarnings || [])];
|
|
254
|
+
const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.warnings || []), ...(plan.bundleWarnings || [])];
|
|
240
255
|
|
|
241
256
|
renderPreview(stdout, plan, warnings);
|
|
242
257
|
const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
|
|
@@ -339,7 +354,8 @@ async function runConfigureAgents(options = {}) {
|
|
|
339
354
|
result.warnings.push(...collectUnresolvedPlaceholderWarnings(plan, result.created));
|
|
340
355
|
|
|
341
356
|
printResultReport(stdout, result, plan.deferred);
|
|
342
|
-
|
|
357
|
+
const usedSnippetFallback = plan.items.some((item) => item.snippetFallback);
|
|
358
|
+
printConfigureAgentsNextSteps(stdout, Boolean(options.commandAdapters), usedSnippetFallback);
|
|
343
359
|
return 0;
|
|
344
360
|
} catch (error) {
|
|
345
361
|
if (rl) {
|
|
@@ -390,13 +406,6 @@ async function runPreflight(cwd) {
|
|
|
390
406
|
warnings.push('Found empty dflow/specs/. Continuing because no initialized files were found.');
|
|
391
407
|
}
|
|
392
408
|
|
|
393
|
-
const legacySpecsPath = path.join(cwd, 'specs');
|
|
394
|
-
if ((await pathExists(legacySpecsPath)) && (await containsInitializedContent(legacySpecsPath))) {
|
|
395
|
-
warnings.push(
|
|
396
|
-
'Detected legacy specs/. Dflow V1 will not migrate or modify it; new files will be created under dflow/specs/. See docs/migrating-to-dflow-v1.md for the manual migration checklist.'
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
409
|
await assertWritableProjectRoot(cwd);
|
|
401
410
|
|
|
402
411
|
if (compareVersions(process.versions.node, MIN_NODE_VERSION) < 0) {
|
|
@@ -531,7 +540,13 @@ async function detectConfiguredAgents(cwd) {
|
|
|
531
540
|
// can default to them instead of re-asking from scratch on every invocation.
|
|
532
541
|
// Order matches AI_AGENT_OPTIONS so the prompt numbering lines up.
|
|
533
542
|
const detected = [];
|
|
534
|
-
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
|
+
) {
|
|
535
550
|
detected.push('agents');
|
|
536
551
|
}
|
|
537
552
|
if (
|
|
@@ -541,7 +556,12 @@ async function detectConfiguredAgents(cwd) {
|
|
|
541
556
|
) {
|
|
542
557
|
detected.push('claude');
|
|
543
558
|
}
|
|
544
|
-
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
|
+
) {
|
|
545
565
|
detected.push('copilot');
|
|
546
566
|
}
|
|
547
567
|
return detected;
|
|
@@ -869,7 +889,7 @@ async function askAiAgents(rl, stdout, stderr, defaultKeys = []) {
|
|
|
869
889
|
if (!parsed.valid) {
|
|
870
890
|
failedAttempts += 1;
|
|
871
891
|
if (failedAttempts >= 3) {
|
|
872
|
-
throw new InitError('Too many invalid attempts for
|
|
892
|
+
throw new InitError('Too many invalid attempts for Q8. Dflow init aborted.');
|
|
873
893
|
}
|
|
874
894
|
stderr.write(`${parsed.message} (${3 - failedAttempts} attempts left)\n`);
|
|
875
895
|
continue;
|
|
@@ -1089,10 +1109,16 @@ async function buildFilePlan(cwd, answers) {
|
|
|
1089
1109
|
await addTemplate('dflow/specs/shared/Git-principles-trunk.md', 'scaffolding/Git-principles-trunk.md', 'mandatory, selected Git policy');
|
|
1090
1110
|
}
|
|
1091
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 = [];
|
|
1092
1118
|
if (answers.aiAgents.length > 0) {
|
|
1093
1119
|
await addTemplate('dflow/specs/shared/AI-AGENT-GUIDE.md', 'scaffolding/AI-AGENT-GUIDE.md', 'selected, canonical AI agent guide');
|
|
1094
1120
|
for (const agent of answers.aiAgents) {
|
|
1095
|
-
await addAiAgentShim(cwd, items, agent, substitution);
|
|
1121
|
+
await addAiAgentShim(cwd, items, agent, substitution, { warnings });
|
|
1096
1122
|
}
|
|
1097
1123
|
}
|
|
1098
1124
|
|
|
@@ -1106,6 +1132,7 @@ async function buildFilePlan(cwd, answers) {
|
|
|
1106
1132
|
items,
|
|
1107
1133
|
deferred: buildDeferredItems(answers.edition),
|
|
1108
1134
|
bundleWarnings,
|
|
1135
|
+
warnings,
|
|
1109
1136
|
unresolvedInitPlaceholders: Array.from(substitution.entries())
|
|
1110
1137
|
.filter(([placeholder, value]) => placeholder === value)
|
|
1111
1138
|
.map(([placeholder]) => placeholder)
|
|
@@ -1167,6 +1194,16 @@ async function buildConfigureAgentsPlan(cwd, answers) {
|
|
|
1167
1194
|
|
|
1168
1195
|
async function finalizePlanItems(cwd, items) {
|
|
1169
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
|
+
}
|
|
1170
1207
|
const absolute = path.join(cwd, item.relativePath);
|
|
1171
1208
|
const targetExists = await pathExists(absolute);
|
|
1172
1209
|
item.action = targetExists ? (item.overwrite ? 'update' : 'skip') : 'create';
|
|
@@ -1177,42 +1214,148 @@ async function finalizePlanItems(cwd, items) {
|
|
|
1177
1214
|
}
|
|
1178
1215
|
}
|
|
1179
1216
|
|
|
1217
|
+
// A workflow bundle file name must be unique across the common and edition
|
|
1218
|
+
// source trees, so the merged dest path / manifest entry never collide or
|
|
1219
|
+
// shadow each other. Pure (no I/O) so it is unit-testable on a synthetic list.
|
|
1220
|
+
function assertNoBundleCollision(files) {
|
|
1221
|
+
const seenBy = new Map();
|
|
1222
|
+
for (const f of files) {
|
|
1223
|
+
const prior = seenBy.get(f.sourceRel);
|
|
1224
|
+
if (prior && prior !== f.sourceRoot) {
|
|
1225
|
+
throw new InitError(
|
|
1226
|
+
`Internal error: workflow bundle file "${f.sourceRel}" exists in both templates/${prior}/ and templates/${f.sourceRoot}/. A bundle file name must be unique across the common and edition source trees.`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
seenBy.set(f.sourceRel, f.sourceRoot);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// R3-02 (sharpened for the PROPOSAL-064 common merge): the EDITION tree itself
|
|
1234
|
+
// must contribute both the flow docs (references/) and the blank templates
|
|
1235
|
+
// (templates/) — the common tree must NOT mask a broken edition (a vanished
|
|
1236
|
+
// templates/{edition}/references/ would otherwise be hidden by common's
|
|
1237
|
+
// non-empty references/). Enforced in the scanner (not only the projector) so
|
|
1238
|
+
// BOTH callers are covered: the projector hard-fails, and doctor's existing
|
|
1239
|
+
// try/catch around listBundleSourceFiles degrades to skipping the orphan scan
|
|
1240
|
+
// rather than mis-reporting every projected flow file as orphaned. Pure (no
|
|
1241
|
+
// I/O) so it is unit-testable on a synthetic list.
|
|
1242
|
+
function assertEditionBundleComplete(files, edition) {
|
|
1243
|
+
const hasEditionRefs = files.some((f) => f.sourceRoot === edition && f.dir === 'references');
|
|
1244
|
+
const hasEditionTemplates = files.some((f) => f.sourceRoot === edition && f.dir === 'templates');
|
|
1245
|
+
if (!hasEditionRefs || !hasEditionTemplates) {
|
|
1246
|
+
throw new InitError(
|
|
1247
|
+
`Internal error: incomplete workflow bundle source for edition "${edition}" (expected files under both templates/${edition}/references/ and templates/${edition}/templates/). The installed dflow package looks incomplete.`
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Companion to assertEditionBundleComplete for the common tree: every file the
|
|
1253
|
+
// common bundle MUST provide has to be present. A common file silently missing
|
|
1254
|
+
// (broken package / tarball) would otherwise slip through as a smaller-but-valid
|
|
1255
|
+
// set and, on re-projection, be DELETED from the user's project by stale-removal
|
|
1256
|
+
// (the manifest diff would classify the still-installed copy as "retired").
|
|
1257
|
+
// Hard-fail here, before stale cleanup / manifest write. Pure (no I/O) so it is
|
|
1258
|
+
// unit-testable on a synthetic list.
|
|
1259
|
+
function assertCommonBundleComplete(files) {
|
|
1260
|
+
const present = new Set(files.filter((f) => f.sourceRoot === 'common').map((f) => f.sourceRel));
|
|
1261
|
+
for (const required of REQUIRED_COMMON_BUNDLE_FILES) {
|
|
1262
|
+
if (!present.has(required)) {
|
|
1263
|
+
throw new InitError(
|
|
1264
|
+
`Internal error: missing required common workflow bundle file templates/common/${required}. The installed dflow package looks incomplete.`
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Bundle source files come from two trees, merged: the edition-neutral common
|
|
1271
|
+
// tree (templates/common/, PROPOSAL-064) and the per-edition tree
|
|
1272
|
+
// (templates/{edition}/). Each descriptor carries its sourceRoot so the reader
|
|
1273
|
+
// (readPackagedBundleFile) loads content from the right tree; the projected dest
|
|
1274
|
+
// path and the manifest stay keyed on sourceRel, so a common-sourced file lands
|
|
1275
|
+
// at the same dflow/.../references/<name> path in every edition. The scanner
|
|
1276
|
+
// validates the merged set (collision + complete-edition guards) before
|
|
1277
|
+
// returning, so both callers (projector, doctor) get a trustworthy list.
|
|
1180
1278
|
async function listBundleSourceFiles(edition) {
|
|
1181
1279
|
const bundleDirs = ['references', 'templates'];
|
|
1280
|
+
const sourceRoots = ['common', edition];
|
|
1182
1281
|
const files = [];
|
|
1183
1282
|
|
|
1184
|
-
for (const
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1283
|
+
for (const sourceRoot of sourceRoots) {
|
|
1284
|
+
for (const dir of bundleDirs) {
|
|
1285
|
+
const sourceDir = path.join(TEMPLATE_ROOT, sourceRoot, dir);
|
|
1286
|
+
let entries;
|
|
1287
|
+
try {
|
|
1288
|
+
entries = await fs.readdir(sourceDir);
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
if (error.code === 'ENOENT') {
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
throw error;
|
|
1192
1294
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
if (stat.isFile()) {
|
|
1200
|
-
files.push({ sourceRel, dir, name: entry });
|
|
1295
|
+
for (const entry of entries) {
|
|
1296
|
+
const sourcePath = path.join(sourceDir, entry);
|
|
1297
|
+
const stat = await fs.stat(sourcePath);
|
|
1298
|
+
if (stat.isFile()) {
|
|
1299
|
+
files.push({ sourceRel: `${dir}/${entry}`, dir, name: entry, sourceRoot });
|
|
1300
|
+
}
|
|
1201
1301
|
}
|
|
1202
1302
|
}
|
|
1203
1303
|
}
|
|
1204
1304
|
|
|
1305
|
+
assertNoBundleCollision(files);
|
|
1306
|
+
assertEditionBundleComplete(files, edition);
|
|
1307
|
+
assertCommonBundleComplete(files);
|
|
1205
1308
|
return files;
|
|
1206
1309
|
}
|
|
1207
1310
|
|
|
1311
|
+
// Reads the per-project workflow bundle manifest, distinguishing "absent"
|
|
1312
|
+
// (normal: fresh init / first projection) from "corrupt" (unreadable or invalid
|
|
1313
|
+
// shape). A corrupt manifest must NOT be treated as absent: that would silently
|
|
1314
|
+
// disable stale cleanup and then overwrite the (recoverable) record. Callers
|
|
1315
|
+
// degrade on corrupt — skip cleanup, skip the manifest write, still project —
|
|
1316
|
+
// rather than hard-fail, because a corrupt project manifest is a user-project
|
|
1317
|
+
// state, not a broken package. An empty `files: []` is a valid manifest.
|
|
1208
1318
|
async function readCurrentBundleManifest(cwd) {
|
|
1209
1319
|
const manifestPath = path.join(cwd, WORKFLOW_BUNDLE_MANIFEST_PATH);
|
|
1320
|
+
let raw;
|
|
1321
|
+
try {
|
|
1322
|
+
raw = await fs.readFile(manifestPath, 'utf8');
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
if (error.code === 'ENOENT') {
|
|
1325
|
+
return { kind: 'absent' };
|
|
1326
|
+
}
|
|
1327
|
+
return { kind: 'corrupt', reason: `cannot read manifest (${error.code || error.message})` };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
let parsed;
|
|
1210
1331
|
try {
|
|
1211
|
-
|
|
1212
|
-
return JSON.parse(raw);
|
|
1332
|
+
parsed = JSON.parse(raw);
|
|
1213
1333
|
} catch {
|
|
1214
|
-
return
|
|
1334
|
+
return { kind: 'corrupt', reason: 'manifest is not valid JSON' };
|
|
1335
|
+
}
|
|
1336
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1337
|
+
return { kind: 'corrupt', reason: 'manifest is not a JSON object' };
|
|
1215
1338
|
}
|
|
1339
|
+
if (!Array.isArray(parsed.files) || !parsed.files.every((entry) => typeof entry === 'string')) {
|
|
1340
|
+
return { kind: 'corrupt', reason: 'manifest "files" is not an array of strings' };
|
|
1341
|
+
}
|
|
1342
|
+
return { kind: 'ok', manifest: parsed };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// True when a manifest `files` entry is a canonical, in-bundle, traversal-free
|
|
1346
|
+
// relative path. Manifest entries are produced canonically; a non-canonical
|
|
1347
|
+
// entry (hand-edited / corrupt manifest, e.g. one containing "..") is NOT acted
|
|
1348
|
+
// on, because (a) it cannot be reliably string-matched against the current
|
|
1349
|
+
// bundle set — so a path that *resolves* to a current file would otherwise be
|
|
1350
|
+
// scheduled for removal — and (b) it drives an unlink. Verifying canonical form
|
|
1351
|
+
// up front both blocks traversal and makes the newRelPaths string compare
|
|
1352
|
+
// reliable.
|
|
1353
|
+
function isCanonicalBundlePath(relPath) {
|
|
1354
|
+
if (typeof relPath !== 'string' || relPath.length === 0) return false;
|
|
1355
|
+
if (relPath.includes('\\')) return false;
|
|
1356
|
+
if (path.posix.normalize(relPath) !== relPath) return false;
|
|
1357
|
+
const prefix = `${WORKFLOW_BUNDLE_DEST}/`;
|
|
1358
|
+
return relPath.startsWith(prefix) && relPath.length > prefix.length;
|
|
1216
1359
|
}
|
|
1217
1360
|
|
|
1218
1361
|
function buildBundleManifest(edition, version, files) {
|
|
@@ -1229,54 +1372,94 @@ function injectBundleMarker(content) {
|
|
|
1229
1372
|
}
|
|
1230
1373
|
|
|
1231
1374
|
async function addWorkflowBundleItems(cwd, items, warnings, edition) {
|
|
1375
|
+
// listBundleSourceFiles merges templates/common/ + templates/{edition}/ and
|
|
1376
|
+
// validates the merged set (collision + complete-edition guards) before
|
|
1377
|
+
// returning, so the projector can trust a complete set here. A broken package
|
|
1378
|
+
// throws (InitError) before any stale removal or manifest write.
|
|
1232
1379
|
const bundleFiles = await listBundleSourceFiles(edition);
|
|
1233
1380
|
|
|
1234
|
-
|
|
1235
|
-
const existingManifest = await readCurrentBundleManifest(cwd);
|
|
1236
|
-
const previousEdition = existingManifest ? existingManifest.edition : null;
|
|
1381
|
+
const newRelPaths = new Set(bundleFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
|
|
1237
1382
|
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1383
|
+
// Read the previous manifest to drive stale cleanup. Distinguish absent
|
|
1384
|
+
// (normal) from corrupt (degrade): on corrupt, skip cleanup AND skip the
|
|
1385
|
+
// manifest write below — never hard-fail, since a corrupt project manifest is
|
|
1386
|
+
// a user-project state (the package may be healthy and the user may be running
|
|
1387
|
+
// configure-agents precisely to repair / update).
|
|
1388
|
+
const manifestResult = await readCurrentBundleManifest(cwd);
|
|
1389
|
+
const manifestCorrupt = manifestResult.kind === 'corrupt';
|
|
1390
|
+
if (manifestCorrupt) {
|
|
1391
|
+
warnings.push(
|
|
1392
|
+
`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.`
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
const existingManifest = manifestResult.kind === 'ok' ? manifestResult.manifest : null;
|
|
1396
|
+
|
|
1397
|
+
// Stale removal (generalized from edition-change-only to a manifest diff):
|
|
1398
|
+
// remove any path the previous manifest recorded but the current bundle source
|
|
1399
|
+
// no longer includes. Edition change is just the case where the whole old set
|
|
1400
|
+
// differs; a same-edition file-set shrink (e.g. a retired bundle file) is
|
|
1401
|
+
// handled identically. The marker check (here) + content re-check (apply
|
|
1402
|
+
// phase) still guard against deleting user-modified files.
|
|
1403
|
+
if (existingManifest) {
|
|
1404
|
+
const previousEdition = existingManifest.edition;
|
|
1405
|
+
const editionChanged = Boolean(previousEdition) && previousEdition !== edition;
|
|
1406
|
+
for (const staleRelPath of existingManifest.files) {
|
|
1407
|
+
// Act only on canonical, in-bundle, traversal-free entries. A
|
|
1408
|
+
// non-canonical entry (hand-edited / corrupt manifest) is skipped: acting
|
|
1409
|
+
// on it would make the newRelPaths string compare unreliable (a path that
|
|
1410
|
+
// resolves to a current bundle file could be scheduled for removal) and it
|
|
1411
|
+
// drives an unlink. Checked FIRST so the membership compare below is sound.
|
|
1412
|
+
if (!isCanonicalBundlePath(staleRelPath)) {
|
|
1413
|
+
warnings.push(
|
|
1414
|
+
`Ignored non-canonical workflow bundle manifest path: ${staleRelPath}`
|
|
1415
|
+
);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
// Still a current bundle file → it will be updated, not removed.
|
|
1419
|
+
if (newRelPaths.has(staleRelPath)) {
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1242
1422
|
const staleAbsPath = path.join(cwd, staleRelPath);
|
|
1243
|
-
let
|
|
1423
|
+
let staleStat = null;
|
|
1244
1424
|
try {
|
|
1245
|
-
await fs.stat(staleAbsPath);
|
|
1246
|
-
staleExists = true;
|
|
1425
|
+
staleStat = await fs.stat(staleAbsPath);
|
|
1247
1426
|
} catch {
|
|
1248
|
-
|
|
1427
|
+
staleStat = null;
|
|
1428
|
+
}
|
|
1429
|
+
if (!staleStat) {
|
|
1430
|
+
continue;
|
|
1249
1431
|
}
|
|
1250
|
-
|
|
1432
|
+
// A non-file entry (e.g. a directory path in a hand-edited / corrupt
|
|
1433
|
+
// manifest) must degrade gracefully, not crash readFile with EISDIR.
|
|
1434
|
+
if (!staleStat.isFile()) {
|
|
1435
|
+
warnings.push(`Ignored non-file workflow bundle manifest entry: ${staleRelPath}`);
|
|
1251
1436
|
continue;
|
|
1252
1437
|
}
|
|
1253
1438
|
const staleContent = await fs.readFile(staleAbsPath, 'utf8');
|
|
1254
1439
|
if (!staleContent.includes(WORKFLOW_BUNDLE_GENERATED_MARKER)) {
|
|
1255
1440
|
warnings.push(
|
|
1256
|
-
`
|
|
1441
|
+
`Retired workflow bundle file is user-modified; left unchanged: ${staleRelPath}`
|
|
1257
1442
|
);
|
|
1258
1443
|
continue;
|
|
1259
1444
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
});
|
|
1271
|
-
}
|
|
1445
|
+
items.push({
|
|
1446
|
+
relativePath: staleRelPath,
|
|
1447
|
+
source: editionChanged ? `stale-bundle:${previousEdition}` : 'stale-bundle:retired',
|
|
1448
|
+
notes: editionChanged
|
|
1449
|
+
? `stale workflow bundle file from ${previousEdition} edition`
|
|
1450
|
+
: 'retired workflow bundle file no longer in the package source',
|
|
1451
|
+
action: 'remove',
|
|
1452
|
+
size: Buffer.byteLength(staleContent, 'utf8'),
|
|
1453
|
+
expectedContent: staleContent
|
|
1454
|
+
});
|
|
1272
1455
|
}
|
|
1273
1456
|
}
|
|
1274
1457
|
|
|
1275
1458
|
// Build items for current edition bundle files.
|
|
1276
|
-
for (const { sourceRel } of bundleFiles) {
|
|
1459
|
+
for (const { sourceRel, sourceRoot } of bundleFiles) {
|
|
1277
1460
|
const relativePath = `${WORKFLOW_BUNDLE_DEST}/${sourceRel}`;
|
|
1278
1461
|
const absolutePath = path.join(cwd, relativePath);
|
|
1279
|
-
const sourceContent = await readPackagedBundleFile(
|
|
1462
|
+
const sourceContent = await readPackagedBundleFile(sourceRoot, sourceRel);
|
|
1280
1463
|
const content = injectBundleMarker(sourceContent);
|
|
1281
1464
|
|
|
1282
1465
|
let action;
|
|
@@ -1300,7 +1483,7 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
|
|
|
1300
1483
|
|
|
1301
1484
|
items.push({
|
|
1302
1485
|
relativePath,
|
|
1303
|
-
source: `packaged-bundle:${
|
|
1486
|
+
source: `packaged-bundle:${sourceRoot}/${sourceRel}`,
|
|
1304
1487
|
notes,
|
|
1305
1488
|
content,
|
|
1306
1489
|
action,
|
|
@@ -1309,32 +1492,40 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
|
|
|
1309
1492
|
});
|
|
1310
1493
|
}
|
|
1311
1494
|
|
|
1312
|
-
// Add the manifest file
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1495
|
+
// Add the manifest file — unless the existing manifest is corrupt, in which
|
|
1496
|
+
// case leave it untouched (overwriting would destroy the only recoverable
|
|
1497
|
+
// record and silently recreate the manifest-orphan problem).
|
|
1498
|
+
if (!manifestCorrupt) {
|
|
1499
|
+
const manifestContent = JSON.stringify(
|
|
1500
|
+
buildBundleManifest(edition, pkg.version, bundleFiles),
|
|
1501
|
+
null,
|
|
1502
|
+
2
|
|
1503
|
+
) + '\n';
|
|
1504
|
+
const manifestExists = await pathExists(path.join(cwd, WORKFLOW_BUNDLE_MANIFEST_PATH));
|
|
1319
1505
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1506
|
+
items.push({
|
|
1507
|
+
relativePath: WORKFLOW_BUNDLE_MANIFEST_PATH,
|
|
1508
|
+
source: `generated:workflow-bundle-manifest`,
|
|
1509
|
+
notes: 'workflow bundle manifest',
|
|
1510
|
+
content: manifestContent,
|
|
1511
|
+
action: manifestExists ? 'update' : 'create',
|
|
1512
|
+
overwrite: true,
|
|
1513
|
+
size: Buffer.byteLength(manifestContent, 'utf8')
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1329
1516
|
}
|
|
1330
1517
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1518
|
+
// Reads one bundle file's content from its source tree. sourceRoot is the
|
|
1519
|
+
// descriptor's tree ('common' or an edition) so a common-sourced file is read
|
|
1520
|
+
// from templates/common/, not templates/{edition}/ (PROPOSAL-064). The traversal
|
|
1521
|
+
// guard is re-rooted at templates/${sourceRoot}/ accordingly.
|
|
1522
|
+
async function readPackagedBundleFile(sourceRoot, sourceRel) {
|
|
1523
|
+
const filePath = path.join(TEMPLATE_ROOT, sourceRoot, sourceRel);
|
|
1524
|
+
const normalizedRoot = path.resolve(TEMPLATE_ROOT, sourceRoot);
|
|
1334
1525
|
const normalizedFilePath = path.resolve(filePath);
|
|
1335
1526
|
|
|
1336
1527
|
if (!normalizedFilePath.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
1337
|
-
throw new InitError(`Internal error: packaged bundle file not found: templates/${
|
|
1528
|
+
throw new InitError(`Internal error: packaged bundle file not found: templates/${sourceRoot}/${sourceRel}`);
|
|
1338
1529
|
}
|
|
1339
1530
|
|
|
1340
1531
|
let buffer;
|
|
@@ -1342,90 +1533,367 @@ async function readPackagedBundleFile(edition, sourceRel) {
|
|
|
1342
1533
|
buffer = await fs.readFile(normalizedFilePath);
|
|
1343
1534
|
} catch (error) {
|
|
1344
1535
|
if (error.code === 'ENOENT') {
|
|
1345
|
-
throw new InitError(`Internal error: packaged bundle file not found: templates/${
|
|
1536
|
+
throw new InitError(`Internal error: packaged bundle file not found: templates/${sourceRoot}/${sourceRel}`);
|
|
1346
1537
|
}
|
|
1347
|
-
throw new InitError(`Internal error: cannot read packaged bundle file: templates/${
|
|
1538
|
+
throw new InitError(`Internal error: cannot read packaged bundle file: templates/${sourceRoot}/${sourceRel}`);
|
|
1348
1539
|
}
|
|
1349
1540
|
|
|
1350
1541
|
try {
|
|
1351
1542
|
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
1352
1543
|
} catch {
|
|
1353
|
-
throw new InitError(`Internal error: invalid UTF-8 packaged bundle file: templates/${
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1544
|
+
throw new InitError(`Internal error: invalid UTF-8 packaged bundle file: templates/${sourceRoot}/${sourceRel}`);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// PROPOSAL-054: configure a tool's root agent file. A non-guide existing file used
|
|
1549
|
+
// to be parked as a side merge snippet ("hand-merge this yourself"); it is now an
|
|
1550
|
+
// auto-injected, marker-delimited Dflow block shown in the confirmation preview.
|
|
1551
|
+
// The snippet + warning survive only as a genuine-conflict fallback. Decision table
|
|
1552
|
+
// (read existing file content, then branch):
|
|
1553
|
+
// 1. not exists -> create marker-free whole-file shim
|
|
1554
|
+
// 2a. pristine / prior whole-file shim -> regenerate in place (idempotent / migrate)
|
|
1555
|
+
// 2b. one well-formed agent-shim block -> replace that block (idempotent re-run)
|
|
1556
|
+
// 2c. malformed Dflow markers -> snippet fallback + warning (file untouched)
|
|
1557
|
+
// 2d. references guide, not 2a/2b/2c -> skip base shim (Codex: still upsert trigger, OQ#6c)
|
|
1558
|
+
// 2e. user-owned, non-guide existing -> append the marked block(s) <- core change
|
|
1559
|
+
// For Codex + --command-adapters the base-shim block and the trigger block are two
|
|
1560
|
+
// adjacent, independently-marked regions assembled into ONE plan item (per-item
|
|
1561
|
+
// writes are whole-file, so two items for one path would clobber each other).
|
|
1357
1562
|
async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
|
|
1358
1563
|
const target = getAiAgentTarget(agent);
|
|
1359
1564
|
const targetPath = path.join(cwd, target.relativePath);
|
|
1360
|
-
const targetExists = await pathExists(targetPath);
|
|
1361
|
-
const targetConfigured = targetExists && await fileReferencesAiAgentGuide(targetPath);
|
|
1362
1565
|
const commandRegistry = options.commandRegistry || [];
|
|
1363
1566
|
const warnings = options.warnings;
|
|
1364
|
-
const
|
|
1365
|
-
const
|
|
1567
|
+
const isCodex = target.relativePath === 'AGENTS.md';
|
|
1568
|
+
const wantsTrigger = isCodex && commandRegistry.length > 0;
|
|
1366
1569
|
const source = `generated:${agent}-shim`;
|
|
1367
1570
|
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1571
|
+
// Marker-free whole-file forms. `fullShim` is what a freshly created or regenerated
|
|
1572
|
+
// file contains (for Codex + --command-adapters it already embeds the trigger).
|
|
1573
|
+
// `baseShimBody` is the trigger-free shim wrapped in agent-shim markers when
|
|
1574
|
+
// appended into a user-owned file.
|
|
1575
|
+
const baseShimBody = substitutePlaceholders(buildAiAgentShim(target.relativePath), substitution);
|
|
1576
|
+
const fullShim = substitutePlaceholders(buildAiAgentShim(target.relativePath, commandRegistry), substitution);
|
|
1577
|
+
const agentShimBlock = wrapAgentShimBlock(baseShimBody);
|
|
1578
|
+
const triggerBlock = wantsTrigger
|
|
1579
|
+
? substitutePlaceholders(buildCodexCommandTriggerSection(commandRegistry), substitution).trim()
|
|
1580
|
+
: '';
|
|
1581
|
+
|
|
1582
|
+
// Case 1 — create the marker-free whole-file shim. A re-run recognizes it through
|
|
1583
|
+
// the normalized template match (case 2a), so it needs no marker of its own.
|
|
1584
|
+
if (!(await pathExists(targetPath))) {
|
|
1585
|
+
items.push({
|
|
1586
|
+
relativePath: target.relativePath,
|
|
1587
|
+
source,
|
|
1588
|
+
notes: 'selected, tool-specific shim',
|
|
1589
|
+
content: fullShim,
|
|
1590
|
+
action: 'create',
|
|
1591
|
+
size: Buffer.byteLength(fullShim, 'utf8')
|
|
1592
|
+
});
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const existingContent = await fs.readFile(targetPath, 'utf8');
|
|
1597
|
+
const eol = detectDominantEol(existingContent);
|
|
1598
|
+
const lf = existingContent.replace(/\r\n/g, '\n');
|
|
1599
|
+
const agentRegion = classifyMarkedRegion(lf, AGENT_SHIM_SECTION_START, AGENT_SHIM_SECTION_END);
|
|
1600
|
+
// Classify the Codex trigger region on EVERY AGENTS.md run (not only when we are
|
|
1601
|
+
// about to manage the trigger): even a non---command-adapters run replaces the
|
|
1602
|
+
// agent-shim region, and a trigger region that overlaps it would be corrupted by
|
|
1603
|
+
// that slice. We only need the region for the safety gate below; trigger writes
|
|
1604
|
+
// still happen only when wantsTrigger.
|
|
1605
|
+
const triggerRegion = isCodex
|
|
1606
|
+
? classifyMarkedRegion(lf, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END)
|
|
1607
|
+
: { state: 'absent' };
|
|
1608
|
+
|
|
1609
|
+
// Case 2c — Dflow markers cannot be edited safely; fall back to a previewed merge
|
|
1610
|
+
// snippet + warning and leave the file untouched. Three unsafe shapes:
|
|
1611
|
+
// - the agent-shim marker pair is malformed (can't locate the block to manage);
|
|
1612
|
+
// - we are managing the trigger (--command-adapters) and the trigger pair is
|
|
1613
|
+
// malformed (can't locate the block to update);
|
|
1614
|
+
// - the agent-shim and trigger regions overlap / interleave, which the independent
|
|
1615
|
+
// region slices below would corrupt. This is checked on EVERY AGENTS.md run,
|
|
1616
|
+
// adapter or not, because case 2b slices the agent-shim region regardless.
|
|
1617
|
+
// A malformed trigger on a NON-adapter run is deliberately NOT a blanket fallback: we
|
|
1618
|
+
// never touch the trigger there. But slicing the agent-shim block IS unsafe when
|
|
1619
|
+
// trigger markers straddle its boundary (some inside, some outside) — removing the
|
|
1620
|
+
// inside one(s) can promote the remaining outside markers into a new well-formed
|
|
1621
|
+
// trigger region wrapping the regenerated block. `triggerStraddlesAgent` catches that
|
|
1622
|
+
// on every AGENTS.md run (a fully-inside set is cleaned with the block, a fully-
|
|
1623
|
+
// outside set is untouched — both safe). The fallback ALWAYS warns (also closing the
|
|
1624
|
+
// R2-02 asymmetry). When only the trigger pair is malformed in a guide-configured file
|
|
1625
|
+
// under --command-adapters (no agent/overlap/straddle issue), the base shim is already
|
|
1626
|
+
// present, so the fallback is the trigger-only snippet (OQ#6c).
|
|
1627
|
+
const regionsOverlap = agentRegion.state === 'present' && triggerRegion.state === 'present' &&
|
|
1628
|
+
agentRegion.startIdx < triggerRegion.endIdx && triggerRegion.startIdx < agentRegion.endIdx;
|
|
1629
|
+
const triggerStraddlesAgent = isCodex && agentRegion.state === 'present' &&
|
|
1630
|
+
codexTriggerMarkersStraddle(lf, agentRegion.startIdx, agentRegion.endIdx);
|
|
1631
|
+
if (agentRegion.state === 'malformed' || (wantsTrigger && triggerRegion.state === 'malformed') ||
|
|
1632
|
+
regionsOverlap || triggerStraddlesAgent) {
|
|
1633
|
+
const triggerOnlyFallback = wantsTrigger && triggerRegion.state === 'malformed' &&
|
|
1634
|
+
agentRegion.state !== 'malformed' && !regionsOverlap && !triggerStraddlesAgent &&
|
|
1635
|
+
contentReferencesAiAgentGuide(existingContent);
|
|
1636
|
+
const snippetPath = triggerOnlyFallback
|
|
1637
|
+
? 'dflow/specs/shared/AGENTS-md-command-adapters-snippet.md'
|
|
1638
|
+
: target.snippetPath;
|
|
1639
|
+
const snippetContent = triggerOnlyFallback
|
|
1640
|
+
? substitutePlaceholders(buildCodexCommandTriggerSection(commandRegistry), substitution)
|
|
1641
|
+
: fullShim;
|
|
1385
1642
|
if (warnings) {
|
|
1386
1643
|
warnings.push(
|
|
1387
|
-
`Existing ${target.relativePath}
|
|
1644
|
+
`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.`
|
|
1388
1645
|
);
|
|
1389
1646
|
}
|
|
1390
1647
|
items.push({
|
|
1391
|
-
relativePath:
|
|
1648
|
+
relativePath: snippetPath,
|
|
1392
1649
|
source,
|
|
1393
|
-
notes: `selected, ${target.relativePath}
|
|
1394
|
-
content,
|
|
1395
|
-
overwrite: true
|
|
1650
|
+
notes: `selected, ${target.relativePath} has conflicting Dflow markers; merge this snippet manually`,
|
|
1651
|
+
content: snippetContent,
|
|
1652
|
+
overwrite: true,
|
|
1653
|
+
snippetFallback: true
|
|
1396
1654
|
});
|
|
1397
1655
|
return;
|
|
1398
1656
|
}
|
|
1399
1657
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1658
|
+
// Case 2b — exactly one well-formed agent-shim block: replace it in place. For
|
|
1659
|
+
// Codex + --command-adapters also (re)place the adjacent trigger block in the SAME
|
|
1660
|
+
// item.
|
|
1661
|
+
if (agentRegion.state === 'present') {
|
|
1662
|
+
let updated = lf.slice(0, agentRegion.startIdx) + agentShimBlock + lf.slice(agentRegion.endIdx);
|
|
1663
|
+
if (wantsTrigger) {
|
|
1664
|
+
updated = upsertCodexTriggerBlock(updated, triggerBlock);
|
|
1665
|
+
}
|
|
1666
|
+
pushRootInjectItem(items, {
|
|
1667
|
+
relativePath: target.relativePath,
|
|
1668
|
+
source,
|
|
1669
|
+
notes: `selected, updated Dflow block in existing ${target.relativePath}`,
|
|
1670
|
+
content: applyEol(updated, eol),
|
|
1671
|
+
expectedContent: existingContent
|
|
1672
|
+
});
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// No agent-shim marker below.
|
|
1677
|
+
|
|
1678
|
+
// Case 2a — the whole file is a shim Dflow itself would generate (a pristine
|
|
1679
|
+
// 0.8/0.9 shim, or an earlier whole-file injection): regenerate it (idempotent +
|
|
1680
|
+
// migrate an older template forward), preserving the file's dominant EOL. When this
|
|
1681
|
+
// run is not (re)generating a trigger, keep any trigger block the file already has.
|
|
1682
|
+
if (isPristineDflowAgentsShim(existingContent, baseShimBody, target.relativePath)) {
|
|
1683
|
+
let newWhole = fullShim;
|
|
1684
|
+
if (!wantsTrigger && isCodex) {
|
|
1685
|
+
const existingTrigger = extractCodexTriggerBlock(lf);
|
|
1686
|
+
if (existingTrigger) {
|
|
1687
|
+
newWhole = `${baseShimBody.replace(/\n+$/, '')}\n\n${existingTrigger.trim()}\n`;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
pushRootInjectItem(items, {
|
|
1691
|
+
relativePath: target.relativePath,
|
|
1692
|
+
source,
|
|
1693
|
+
notes: `selected, regenerated Dflow ${target.relativePath} shim`,
|
|
1694
|
+
content: applyEol(newWhole, eol),
|
|
1695
|
+
expectedContent: existingContent
|
|
1696
|
+
});
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Case 2d — the file already references the guide but is neither pristine nor
|
|
1701
|
+
// marker-managed (a guide-configured file the user wrote / heavily edited). Keep
|
|
1702
|
+
// the base shim skipped so we never duplicate their guide pointer. Under Codex
|
|
1703
|
+
// --command-adapters still install / update the self-delimited trigger block (OQ#6c).
|
|
1704
|
+
if (contentReferencesAiAgentGuide(existingContent)) {
|
|
1705
|
+
if (wantsTrigger) {
|
|
1706
|
+
pushRootInjectItem(items, {
|
|
1707
|
+
relativePath: target.relativePath,
|
|
1708
|
+
source,
|
|
1709
|
+
notes: `selected, installed Dflow command triggers into existing ${target.relativePath}`,
|
|
1710
|
+
content: applyEol(upsertCodexTriggerBlock(lf, triggerBlock), eol),
|
|
1711
|
+
expectedContent: existingContent
|
|
1712
|
+
});
|
|
1713
|
+
} else {
|
|
1714
|
+
items.push({
|
|
1715
|
+
relativePath: target.relativePath,
|
|
1716
|
+
source,
|
|
1717
|
+
notes: `selected, ${target.relativePath} already points to AI-AGENT-GUIDE.md`,
|
|
1718
|
+
content: fullShim,
|
|
1719
|
+
action: 'skip',
|
|
1720
|
+
intentionalSkip: true,
|
|
1721
|
+
size: Buffer.byteLength(fullShim, 'utf8')
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
return;
|
|
1410
1725
|
}
|
|
1411
1726
|
|
|
1727
|
+
// Case 2e — user-owned, non-guide existing file: append the marked Dflow block(s)
|
|
1728
|
+
// at end of file (the core new behavior; replaces the old snippet-park). Append-only,
|
|
1729
|
+
// previewed, reversible (delete the block to revert), idempotent on re-run (case 2b).
|
|
1730
|
+
const blocks = wantsTrigger ? [agentShimBlock, triggerBlock] : [agentShimBlock];
|
|
1731
|
+
pushRootInjectItem(items, {
|
|
1732
|
+
relativePath: target.relativePath,
|
|
1733
|
+
source,
|
|
1734
|
+
notes: `selected, appended Dflow block to existing ${target.relativePath}`,
|
|
1735
|
+
content: appendDflowBlocks(existingContent, blocks, eol),
|
|
1736
|
+
expectedContent: existingContent
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// Push one plan item that edits a user-owned root agent file. The write phase
|
|
1741
|
+
// (writeFilePlan rootInject branch) re-reads the file and requires raw-byte equality
|
|
1742
|
+
// with `expectedContent` before writing, so a file changed between preview and write
|
|
1743
|
+
// is never clobbered. When the computed content already equals the file, record a
|
|
1744
|
+
// quiet idempotent skip instead of a no-op write.
|
|
1745
|
+
function pushRootInjectItem(items, { relativePath, source, notes, content, expectedContent }) {
|
|
1746
|
+
const size = Buffer.byteLength(content, 'utf8');
|
|
1747
|
+
if (content === expectedContent) {
|
|
1748
|
+
items.push({
|
|
1749
|
+
relativePath,
|
|
1750
|
+
source,
|
|
1751
|
+
notes: `${notes}; already current`,
|
|
1752
|
+
content,
|
|
1753
|
+
action: 'skip',
|
|
1754
|
+
intentionalSkip: true,
|
|
1755
|
+
size
|
|
1756
|
+
});
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1412
1759
|
items.push({
|
|
1413
1760
|
relativePath,
|
|
1414
1761
|
source,
|
|
1415
1762
|
notes,
|
|
1416
1763
|
content,
|
|
1417
|
-
|
|
1764
|
+
expectedContent,
|
|
1765
|
+
action: 'update',
|
|
1766
|
+
overwrite: true,
|
|
1767
|
+
rootInject: true,
|
|
1768
|
+
size
|
|
1418
1769
|
});
|
|
1419
1770
|
}
|
|
1420
1771
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1772
|
+
function contentReferencesAiAgentGuide(content) {
|
|
1773
|
+
return content.includes('dflow/specs/shared/AI-AGENT-GUIDE.md') ||
|
|
1774
|
+
content.includes('dflow\\specs\\shared\\AI-AGENT-GUIDE.md');
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function wrapAgentShimBlock(shimBody) {
|
|
1778
|
+
const body = shimBody.replace(/\n+$/, '');
|
|
1779
|
+
return `${AGENT_SHIM_SECTION_START}\n${body}\n${AGENT_SHIM_SECTION_END}`;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Append `block` (LF) at end of `lfContent` (LF) with a one-blank-line separator,
|
|
1783
|
+
// preserving the existing content and its final-newline convention exactly — trailing
|
|
1784
|
+
// whitespace is never stripped. Returns LF.
|
|
1785
|
+
function appendBlockLf(lfContent, block) {
|
|
1786
|
+
if (lfContent === '') {
|
|
1787
|
+
return `${block}\n`;
|
|
1788
|
+
}
|
|
1789
|
+
if (lfContent.endsWith('\n\n')) {
|
|
1790
|
+
return `${lfContent}${block}\n`;
|
|
1791
|
+
}
|
|
1792
|
+
if (lfContent.endsWith('\n')) {
|
|
1793
|
+
return `${lfContent}\n${block}\n`;
|
|
1794
|
+
}
|
|
1795
|
+
return `${lfContent}\n\n${block}\n`;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Append Dflow blocks at end of an existing user file. The user's content is kept in
|
|
1799
|
+
// full (never stripped or reordered) and the final-newline convention is preserved —
|
|
1800
|
+
// only a one-blank-line separator is added. The whole result is emitted in the file's
|
|
1801
|
+
// dominant EOL (the approved EOL policy), so a pure LF / pure CRLF user prefix
|
|
1802
|
+
// round-trips byte-for-byte. `blocks` are LF strings.
|
|
1803
|
+
function appendDflowBlocks(existingContent, blocks, eol) {
|
|
1804
|
+
const lf = existingContent.replace(/\r\n/g, '\n');
|
|
1805
|
+
return applyEol(appendBlockLf(lf, blocks.join('\n\n')), eol);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Replace an existing well-formed Codex trigger block, or append one at EOF preserving
|
|
1809
|
+
// the existing content + final-newline convention (no stripping). Assumes the trigger
|
|
1810
|
+
// markers are absent or a single well-formed pair (malformed is handled upstream as a
|
|
1811
|
+
// snippet fallback). `lfContent` / `triggerBlock` are LF strings.
|
|
1812
|
+
function upsertCodexTriggerBlock(lfContent, triggerBlock) {
|
|
1813
|
+
const region = classifyMarkedRegion(lfContent, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
|
|
1814
|
+
if (region.state === 'present') {
|
|
1815
|
+
return lfContent.slice(0, region.startIdx) + triggerBlock + lfContent.slice(region.endIdx);
|
|
1816
|
+
}
|
|
1817
|
+
return appendBlockLf(lfContent, triggerBlock);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function extractCodexTriggerBlock(content) {
|
|
1821
|
+
const region = classifyMarkedRegion(content, CODEX_TRIGGER_SECTION_START, CODEX_TRIGGER_SECTION_END);
|
|
1822
|
+
return region.state === 'present' ? content.slice(region.startIdx, region.endIdx) : null;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Classify a START/END marker pair in `content`:
|
|
1826
|
+
// 'absent' neither marker appears
|
|
1827
|
+
// 'present' exactly one START and one END, in order (startIdx/endIdx returned)
|
|
1828
|
+
// 'malformed' any other shape (partial / duplicated / nested / reversed)
|
|
1829
|
+
// The markers are single-line HTML comments, so classification is identical on raw or
|
|
1830
|
+
// LF-normalized content; callers slice on whichever string they passed in.
|
|
1831
|
+
function classifyMarkedRegion(content, startMarker, endMarker) {
|
|
1832
|
+
const startCount = countOccurrences(content, startMarker);
|
|
1833
|
+
const endCount = countOccurrences(content, endMarker);
|
|
1834
|
+
if (startCount === 0 && endCount === 0) {
|
|
1835
|
+
return { state: 'absent' };
|
|
1836
|
+
}
|
|
1837
|
+
if (startCount === 1 && endCount === 1) {
|
|
1838
|
+
const startIdx = content.indexOf(startMarker);
|
|
1839
|
+
const endInner = content.indexOf(endMarker);
|
|
1840
|
+
if (startIdx < endInner) {
|
|
1841
|
+
return { state: 'present', startIdx, endIdx: endInner + endMarker.length };
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return { state: 'malformed' };
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function countOccurrences(haystack, needle) {
|
|
1848
|
+
if (!needle) {
|
|
1849
|
+
return 0;
|
|
1850
|
+
}
|
|
1851
|
+
let count = 0;
|
|
1852
|
+
let index = haystack.indexOf(needle);
|
|
1853
|
+
while (index !== -1) {
|
|
1854
|
+
count += 1;
|
|
1855
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
1856
|
+
}
|
|
1857
|
+
return count;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function markerPositions(content, marker) {
|
|
1861
|
+
const positions = [];
|
|
1862
|
+
let index = content.indexOf(marker);
|
|
1863
|
+
while (index !== -1) {
|
|
1864
|
+
positions.push(index);
|
|
1865
|
+
index = content.indexOf(marker, index + marker.length);
|
|
1428
1866
|
}
|
|
1867
|
+
return positions;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// True when Codex trigger markers cross the [start, end) boundary — at least one inside
|
|
1871
|
+
// and at least one outside. Slicing [start, end) (the case-2b agent-shim replace) is
|
|
1872
|
+
// then unsafe: removing the inside marker(s) can leave the outside marker(s) forming a
|
|
1873
|
+
// new well-formed trigger region wrapping the regenerated block. All-inside (removed
|
|
1874
|
+
// with the block) and all-outside (untouched) are both safe; only a boundary cross is.
|
|
1875
|
+
function codexTriggerMarkersStraddle(content, start, end) {
|
|
1876
|
+
const positions = [
|
|
1877
|
+
...markerPositions(content, CODEX_TRIGGER_SECTION_START),
|
|
1878
|
+
...markerPositions(content, CODEX_TRIGGER_SECTION_END)
|
|
1879
|
+
];
|
|
1880
|
+
const inside = positions.some((position) => position >= start && position < end);
|
|
1881
|
+
const outside = positions.some((position) => position < start || position >= end);
|
|
1882
|
+
return inside && outside;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Dominant line ending of a user file, so injected blocks match it (Windows projects
|
|
1886
|
+
// may be CRLF). The repo's own LF policy (.gitattributes) governs repo files only, not
|
|
1887
|
+
// an adopter's project files.
|
|
1888
|
+
function detectDominantEol(content) {
|
|
1889
|
+
const crlf = (content.match(/\r\n/g) || []).length;
|
|
1890
|
+
const lfOnly = (content.match(/\n/g) || []).length - crlf;
|
|
1891
|
+
return crlf > lfOnly ? '\r\n' : '\n';
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function applyEol(content, eol) {
|
|
1895
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
1896
|
+
return eol === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
|
|
1429
1897
|
}
|
|
1430
1898
|
|
|
1431
1899
|
function getAiAgentTarget(agent) {
|
|
@@ -1456,9 +1924,36 @@ function buildAiAgentShim(targetPath, commandRegistry = []) {
|
|
|
1456
1924
|
? buildCodexCommandTriggerSection(commandRegistry)
|
|
1457
1925
|
: '';
|
|
1458
1926
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1927
|
+
return `# ${title}
|
|
1928
|
+
|
|
1929
|
+
This project uses Dflow for spec-first AI-assisted development.
|
|
1930
|
+
|
|
1931
|
+
For spec-impacting work — a new feature, a change to product, user-facing, or
|
|
1932
|
+
domain behavior, a new requirement, or a bug-fix workflow — read and follow:
|
|
1933
|
+
|
|
1934
|
+
- \`dflow/specs/shared/AI-AGENT-GUIDE.md\` — command registry, routing rules, and project context.
|
|
1935
|
+
- \`dflow/specs/shared/dflow-workflows/\` — vendored workflow bundle with executable step definitions.
|
|
1936
|
+
|
|
1937
|
+
For routine work (refactors, renames, chores, formatting, dependency bumps, or
|
|
1938
|
+
general code questions), proceed normally; you need not read the guide first.
|
|
1939
|
+
|
|
1940
|
+
Keep tool-specific instruction files small. The guide and workflow bundle are
|
|
1941
|
+
the authoritative sources for Dflow workflow rules, slash-command behavior,
|
|
1942
|
+
spec locations, and SDD/DDD constraints.${commandTriggerHint}
|
|
1943
|
+
`;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// Frozen pre-scoping shim body (the wording shipped through v0.9.0 and the
|
|
1947
|
+
// Phase-2 @import-removal interim: "Before planning or editing code ..."). Used
|
|
1948
|
+
// ONLY by isPristineDflowAgentsShim so an adopter's older whole-file shim is
|
|
1949
|
+
// still recognized as Dflow-generated and regenerated to the current scoped
|
|
1950
|
+
// wording. Changing buildAiAgentShim's body without updating this matcher would
|
|
1951
|
+
// strand old shims on the guide-reference skip path — they would keep the old
|
|
1952
|
+
// body, and for CLAUDE.md the legacy @import.
|
|
1953
|
+
function buildLegacyAgentShimBody(targetPath) {
|
|
1954
|
+
const title = targetPath === '.github/copilot-instructions.md'
|
|
1955
|
+
? 'GitHub Copilot Repository Instructions'
|
|
1956
|
+
: `${targetPath} - Dflow Project Instructions`;
|
|
1462
1957
|
|
|
1463
1958
|
return `# ${title}
|
|
1464
1959
|
|
|
@@ -1471,7 +1966,7 @@ Before planning or editing code, read and follow:
|
|
|
1471
1966
|
|
|
1472
1967
|
Keep tool-specific instruction files small. The guide and workflow bundle are
|
|
1473
1968
|
the authoritative sources for Dflow workflow rules, slash-command behavior,
|
|
1474
|
-
spec locations, and SDD/DDD constraints
|
|
1969
|
+
spec locations, and SDD/DDD constraints.
|
|
1475
1970
|
`;
|
|
1476
1971
|
}
|
|
1477
1972
|
|
|
@@ -1554,52 +2049,84 @@ async function buildDflowSkillAdapter() {
|
|
|
1554
2049
|
return fs.readFile(sourcePath, 'utf8');
|
|
1555
2050
|
}
|
|
1556
2051
|
|
|
2052
|
+
// Project-level skill paths per AI agent. Claude, Codex (the `agents` key,
|
|
2053
|
+
// labeled "Codex / Copilot coding agent"), and GitHub Copilot each get the SAME
|
|
2054
|
+
// edition-neutral thin skill projected to their own canonical skill path.
|
|
2055
|
+
// PROPOSAL-056 generalized this; the Copilot native projection (`.github/skills`)
|
|
2056
|
+
// was un-deferred after a spike confirmed Copilot discovers and auto-triggers a
|
|
2057
|
+
// skill from its own path with the cross-read `.claude`/`.agents` paths removed.
|
|
2058
|
+
// Note: Copilot also cross-reads `.claude/skills` and `.agents/skills`, so a
|
|
2059
|
+
// project that selects Copilot alongside Claude/Codex may surface the same
|
|
2060
|
+
// `dflow` skill from more than one path. The copies Dflow *generates* are
|
|
2061
|
+
// byte-identical (same name, body, and marker), so duplicate generated copies
|
|
2062
|
+
// carry identical behavior — but this duplicate-discovery case is not spiked,
|
|
2063
|
+
// and a pre-existing non-Dflow skill at a cross-read path is left unchanged by
|
|
2064
|
+
// the overwrite guard below and could differ. Remove/rename such a file to
|
|
2065
|
+
// avoid a divergent same-name duplicate.
|
|
2066
|
+
const SKILL_ADAPTER_TARGETS = {
|
|
2067
|
+
claude: { relativePath: '.claude/skills/dflow/SKILL.md', source: 'generated:claude-skill-adapter' },
|
|
2068
|
+
agents: { relativePath: '.agents/skills/dflow/SKILL.md', source: 'generated:agents-skill-adapter' },
|
|
2069
|
+
copilot: { relativePath: '.github/skills/dflow/SKILL.md', source: 'generated:copilot-skill-adapter' }
|
|
2070
|
+
};
|
|
2071
|
+
|
|
1557
2072
|
async function addSkillAdapterItems(cwd, items, aiAgents, skills, warnings) {
|
|
1558
2073
|
if (!skills) {
|
|
1559
2074
|
return;
|
|
1560
2075
|
}
|
|
1561
2076
|
|
|
1562
|
-
|
|
2077
|
+
const skillTargets = aiAgents
|
|
2078
|
+
.filter((agent) => SKILL_ADAPTER_TARGETS[agent])
|
|
2079
|
+
.map((agent) => SKILL_ADAPTER_TARGETS[agent]);
|
|
2080
|
+
|
|
2081
|
+
if (skillTargets.length === 0) {
|
|
2082
|
+
// No skill-capable agent was selected at all. Nothing to project.
|
|
1563
2083
|
warnings.push(
|
|
1564
|
-
'The --skills flag
|
|
2084
|
+
'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.'
|
|
1565
2085
|
);
|
|
1566
2086
|
return;
|
|
1567
2087
|
}
|
|
1568
2088
|
|
|
1569
|
-
const relativePath = '.claude/skills/dflow/SKILL.md';
|
|
1570
|
-
const targetPath = path.join(cwd, relativePath);
|
|
1571
|
-
|
|
1572
2089
|
// The thin skill is edition-neutral (it only points to the per-edition guide),
|
|
1573
2090
|
// so there is nothing edition-specific to go stale — re-running just rewrites
|
|
1574
2091
|
// the same marker-guarded file (idempotent). No LEGACY skill set exists yet
|
|
1575
2092
|
// (skills are new in PROPOSAL-038); future skill cleanup would extend the same
|
|
1576
2093
|
// LEGACY_* / addLegacyCommandAdapterCleanupItems marker-fingerprint pattern.
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2094
|
+
const skillContent = await buildDflowSkillAdapter();
|
|
2095
|
+
|
|
2096
|
+
for (const target of skillTargets) {
|
|
2097
|
+
const targetPath = path.join(cwd, target.relativePath);
|
|
2098
|
+
|
|
2099
|
+
let existingContent;
|
|
2100
|
+
try {
|
|
2101
|
+
existingContent = await fs.readFile(targetPath, 'utf8');
|
|
2102
|
+
} catch (error) {
|
|
2103
|
+
if (error.code !== 'ENOENT') {
|
|
2104
|
+
throw error;
|
|
2105
|
+
}
|
|
2106
|
+
existingContent = undefined;
|
|
1583
2107
|
}
|
|
1584
|
-
existingContent = undefined;
|
|
1585
|
-
}
|
|
1586
2108
|
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
2109
|
+
if (existingContent !== undefined && !existingContent.includes(SKILL_ADAPTER_GENERATED_MARKER)) {
|
|
2110
|
+
warnings.push(
|
|
2111
|
+
`Existing ${target.relativePath} is not a Dflow-generated skill; left unchanged. Remove or rename it to let Dflow manage this skill.`
|
|
2112
|
+
);
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
1593
2115
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
2116
|
+
// addSkillAdapterItems runs AFTER finalizePlanItems, so these items never pass
|
|
2117
|
+
// through that pass — set `action` explicitly here (the preview table and the
|
|
2118
|
+
// result report read item.action directly). `overwrite: true` keeps the write
|
|
2119
|
+
// phase rewriting an existing marker-stamped skill.
|
|
2120
|
+
items.push({
|
|
2121
|
+
relativePath: target.relativePath,
|
|
2122
|
+
source: target.source,
|
|
2123
|
+
notes: 'skill adapter, thin skill pointing to AI-AGENT-GUIDE.md',
|
|
2124
|
+
content: skillContent,
|
|
2125
|
+
size: Buffer.byteLength(skillContent, 'utf8'),
|
|
2126
|
+
overwrite: true,
|
|
2127
|
+
action: existingContent === undefined ? 'create' : 'update'
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
1603
2130
|
}
|
|
1604
2131
|
|
|
1605
2132
|
function buildLegacyCommandAdapterFingerprint(legacy, command) {
|
|
@@ -1647,16 +2174,43 @@ function stripCodexTriggerBlock(content) {
|
|
|
1647
2174
|
return content.replace(re, '\n');
|
|
1648
2175
|
}
|
|
1649
2176
|
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
2177
|
+
// Removes the pre-Phase-2 Markdown `@import` block that old CLAUDE.md shims
|
|
2178
|
+
// appended after the shim body. Operates on already-normalized content (LF, no
|
|
2179
|
+
// trailing whitespace, blank runs collapsed, trimmed — see normalizeShimForMatch),
|
|
2180
|
+
// so editor-added trailing spaces, CRLF, or an extra blank line in an old file
|
|
2181
|
+
// do not defeat the match.
|
|
2182
|
+
function stripLegacyImportSuffix(normalizedContent) {
|
|
2183
|
+
return normalizedContent.replace(
|
|
2184
|
+
/\n+If your tool supports Markdown imports, the canonical guide is imported below:\n+@dflow\/specs\/shared\/AI-AGENT-GUIDE\.md$/,
|
|
2185
|
+
''
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// A CLAUDE.md / AGENTS.md is a safely-injectable Dflow shim when, after removing
|
|
2190
|
+
// any previously-injected trigger block, it matches the shim Dflow itself
|
|
2191
|
+
// generates. This covers a pristine 0.8.0/0.9.0 shim (no marker, normalized
|
|
2192
|
+
// template match) and a shim Dflow already injected into (idempotent
|
|
2193
|
+
// re-projection). For CLAUDE.md ONLY, a pre-Phase-2 shim that still carries the
|
|
2194
|
+
// legacy `@import` also counts and is regenerated WITHOUT it, so the
|
|
2195
|
+
// progressive-disclosure fix reaches existing projects. The import was only ever
|
|
2196
|
+
// generated into CLAUDE.md, so scoping the strip there avoids clobbering a
|
|
2197
|
+
// user-added import block in a hand-edited AGENTS.md / Copilot shim. A
|
|
2198
|
+
// user-edited shim otherwise fails the match and degrades to a snippet.
|
|
2199
|
+
function isPristineDflowAgentsShim(existingContent, baseShim, relativePath) {
|
|
2200
|
+
const target = normalizeShimForMatch(baseShim);
|
|
2201
|
+
let existing = normalizeShimForMatch(stripCodexTriggerBlock(existingContent));
|
|
2202
|
+
if (relativePath === 'CLAUDE.md') {
|
|
2203
|
+
existing = stripLegacyImportSuffix(existing);
|
|
2204
|
+
}
|
|
2205
|
+
if (existing === target) {
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
// Back-compat: older Dflow shims (v0.9.0 / Phase-2 interim) used different body
|
|
2209
|
+
// wording ("Before planning or editing code ..."). Recognize that frozen
|
|
2210
|
+
// wording as pristine so configure-agents regenerates it to the current scoped
|
|
2211
|
+
// wording (and, for CLAUDE.md, drops the legacy @import already stripped above).
|
|
2212
|
+
// Without this, the body reword would strand old shims on the skip path.
|
|
2213
|
+
return existing === normalizeShimForMatch(buildLegacyAgentShimBody(relativePath));
|
|
1660
2214
|
}
|
|
1661
2215
|
|
|
1662
2216
|
function buildCodexCommandTriggerSection(commandRegistry) {
|
|
@@ -2240,7 +2794,7 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2240
2794
|
} catch (error) {
|
|
2241
2795
|
if (error.code === 'ENOENT') {
|
|
2242
2796
|
result.skipped.push(item.relativePath);
|
|
2243
|
-
result.warnings.push(`Skipped missing stale
|
|
2797
|
+
result.warnings.push(`Skipped missing stale file: ${item.relativePath}`);
|
|
2244
2798
|
continue;
|
|
2245
2799
|
}
|
|
2246
2800
|
throw error;
|
|
@@ -2248,14 +2802,14 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2248
2802
|
|
|
2249
2803
|
if (!stats.isFile()) {
|
|
2250
2804
|
result.skipped.push(item.relativePath);
|
|
2251
|
-
result.warnings.push(`Skipped stale
|
|
2805
|
+
result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
|
|
2252
2806
|
continue;
|
|
2253
2807
|
}
|
|
2254
2808
|
|
|
2255
2809
|
const currentContent = await fs.readFile(targetPath, 'utf8');
|
|
2256
2810
|
if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
|
|
2257
2811
|
result.skipped.push(item.relativePath);
|
|
2258
|
-
result.warnings.push(`Skipped stale
|
|
2812
|
+
result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
|
|
2259
2813
|
continue;
|
|
2260
2814
|
}
|
|
2261
2815
|
|
|
@@ -2264,6 +2818,45 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2264
2818
|
continue;
|
|
2265
2819
|
}
|
|
2266
2820
|
|
|
2821
|
+
// PROPOSAL-054: a user-owned root agent file edit (append / replace / regenerate).
|
|
2822
|
+
// Re-read and require RAW-byte equality with the previewed content before writing,
|
|
2823
|
+
// so a file the user changed (or deleted, or replaced with a non-file) between the
|
|
2824
|
+
// preview and the write is never clobbered — skip + warn + ask them to re-run. Raw
|
|
2825
|
+
// equality is intentionally stricter than the normalized compare used for stale
|
|
2826
|
+
// removal: any whitespace / EOL change counts as "changed after preview".
|
|
2827
|
+
if (item.rootInject) {
|
|
2828
|
+
let stats;
|
|
2829
|
+
try {
|
|
2830
|
+
stats = await fs.stat(targetPath);
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
if (error.code === 'ENOENT') {
|
|
2833
|
+
result.skipped.push(item.relativePath);
|
|
2834
|
+
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} no longer exists; re-run to inject the Dflow block.`);
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
throw error;
|
|
2838
|
+
}
|
|
2839
|
+
if (!stats.isFile()) {
|
|
2840
|
+
result.skipped.push(item.relativePath);
|
|
2841
|
+
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} is no longer a regular file; re-run to inject the Dflow block.`);
|
|
2842
|
+
continue;
|
|
2843
|
+
}
|
|
2844
|
+
const currentRaw = await fs.readFile(targetPath, 'utf8');
|
|
2845
|
+
if (currentRaw !== item.expectedContent) {
|
|
2846
|
+
result.skipped.push(item.relativePath);
|
|
2847
|
+
result.warnings.push(`Skipped Dflow block update because ${item.relativePath} changed after the preview; re-run to inject the Dflow block.`);
|
|
2848
|
+
continue;
|
|
2849
|
+
}
|
|
2850
|
+
if (item.content === currentRaw) {
|
|
2851
|
+
result.skipped.push(item.relativePath);
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
2855
|
+
await fs.writeFile(targetPath, item.content);
|
|
2856
|
+
result.updated.push(item.relativePath);
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2267
2860
|
if (await pathExists(targetPath)) {
|
|
2268
2861
|
if (item.overwrite) {
|
|
2269
2862
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
@@ -2273,7 +2866,12 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2273
2866
|
}
|
|
2274
2867
|
|
|
2275
2868
|
result.skipped.push(item.relativePath);
|
|
2276
|
-
|
|
2869
|
+
// PROPOSAL-054: an intentional skip (an already-configured agent file, or an
|
|
2870
|
+
// already-current Dflow block) is expected, not a problem — don't emit the
|
|
2871
|
+
// generic "skipped existing target" warning for it.
|
|
2872
|
+
if (!item.intentionalSkip) {
|
|
2873
|
+
result.warnings.push(`Skipped existing target: ${item.relativePath}`);
|
|
2874
|
+
}
|
|
2277
2875
|
if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
|
|
2278
2876
|
result.warnings.push(
|
|
2279
2877
|
'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.'
|
|
@@ -2282,6 +2880,15 @@ async function writeFilePlan(cwd, plan) {
|
|
|
2282
2880
|
continue;
|
|
2283
2881
|
}
|
|
2284
2882
|
|
|
2883
|
+
// PROPOSAL-054: a plan item previewed as "skip" (an already-configured agent
|
|
2884
|
+
// file, or an already-current Dflow block) must never write. If its target
|
|
2885
|
+
// vanished between preview and write, do nothing — do NOT silently create a
|
|
2886
|
+
// file the preview said would be left alone.
|
|
2887
|
+
if (item.action === 'skip') {
|
|
2888
|
+
result.skipped.push(item.relativePath);
|
|
2889
|
+
continue;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2285
2892
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
2286
2893
|
await fs.writeFile(targetPath, item.content, { flag: 'wx' });
|
|
2287
2894
|
|
|
@@ -2396,20 +3003,25 @@ Recommended next steps:
|
|
|
2396
3003
|
`);
|
|
2397
3004
|
}
|
|
2398
3005
|
|
|
2399
|
-
function printConfigureAgentsNextSteps(stdout, commandAdapters = false) {
|
|
3006
|
+
function printConfigureAgentsNextSteps(stdout, commandAdapters = false, snippetFallback = false) {
|
|
2400
3007
|
const commandAdapterStep = commandAdapters
|
|
2401
3008
|
? '- 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'
|
|
2402
3009
|
: '';
|
|
2403
3010
|
|
|
3011
|
+
// PROPOSAL-054: Dflow now auto-injects the marker-delimited block into existing
|
|
3012
|
+
// agent files, so the merge-snippet step is shown only when a genuine-conflict
|
|
3013
|
+
// fallback snippet was actually written this run.
|
|
3014
|
+
const snippetStep = snippetFallback
|
|
3015
|
+
? '- 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
|
+
: '';
|
|
3017
|
+
|
|
2404
3018
|
stdout.write(`
|
|
2405
3019
|
Dflow AI agent configuration complete.
|
|
2406
3020
|
|
|
2407
3021
|
Recommended next steps:
|
|
2408
3022
|
- Keep AI-agent-specific root files small.
|
|
2409
3023
|
- Put durable workflow changes in dflow/specs/shared/AI-AGENT-GUIDE.md.
|
|
2410
|
-
|
|
2411
|
-
${commandAdapterStep}
|
|
2412
|
-
`);
|
|
3024
|
+
${snippetStep}${commandAdapterStep}`);
|
|
2413
3025
|
}
|
|
2414
3026
|
|
|
2415
3027
|
function printList(stdout, values) {
|
|
@@ -2508,9 +3120,8 @@ async function runDoctor(options = {}) {
|
|
|
2508
3120
|
}
|
|
2509
3121
|
|
|
2510
3122
|
const findings = [];
|
|
2511
|
-
await checkLegacyRootSpecsDir(cwd, findings);
|
|
2512
|
-
await checkLegacySharedDir(cwd, findings);
|
|
2513
3123
|
await checkConventionsDflowVersion(cwd, findings);
|
|
3124
|
+
await checkOrphanedWorkflowBundleFiles(cwd, findings);
|
|
2514
3125
|
|
|
2515
3126
|
printDoctorReport(stdout, cwd, findings);
|
|
2516
3127
|
return 0;
|
|
@@ -2524,36 +3135,6 @@ async function runDoctor(options = {}) {
|
|
|
2524
3135
|
}
|
|
2525
3136
|
}
|
|
2526
3137
|
|
|
2527
|
-
async function checkLegacyRootSpecsDir(cwd, findings) {
|
|
2528
|
-
const legacyPath = path.join(cwd, 'specs');
|
|
2529
|
-
if ((await pathExists(legacyPath)) && (await containsInitializedContent(legacyPath))) {
|
|
2530
|
-
findings.push({
|
|
2531
|
-
level: 'warn',
|
|
2532
|
-
title: 'Legacy specs/ directory at project root',
|
|
2533
|
-
detail: 'V1 layout uses dflow/specs/ instead. The CLI does not modify root specs/.',
|
|
2534
|
-
action: 'See docs/migrating-to-dflow-v1.md (Step 1) for the manual migration steps.'
|
|
2535
|
-
});
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
|
|
2539
|
-
async function checkLegacySharedDir(cwd, findings) {
|
|
2540
|
-
const candidates = [
|
|
2541
|
-
path.join(cwd, 'dflow', 'specs', '_共用'),
|
|
2542
|
-
path.join(cwd, 'specs', '_共用')
|
|
2543
|
-
];
|
|
2544
|
-
for (const candidate of candidates) {
|
|
2545
|
-
if (await pathExists(candidate)) {
|
|
2546
|
-
const rel = normalizePath(path.relative(cwd, candidate));
|
|
2547
|
-
findings.push({
|
|
2548
|
-
level: 'warn',
|
|
2549
|
-
title: `Legacy ${rel}/ directory`,
|
|
2550
|
-
detail: 'V1 layout uses shared/ (canonical English directory name).',
|
|
2551
|
-
action: 'See docs/migrating-to-dflow-v1.md (Step 2) for the rename steps.'
|
|
2552
|
-
});
|
|
2553
|
-
}
|
|
2554
|
-
}
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
3138
|
async function checkConventionsDflowVersion(cwd, findings) {
|
|
2558
3139
|
const conventionsPath = path.join(cwd, 'dflow', 'specs', 'shared', '_conventions.md');
|
|
2559
3140
|
if (!(await pathExists(conventionsPath))) return;
|
|
@@ -2568,12 +3149,75 @@ async function checkConventionsDflowVersion(cwd, findings) {
|
|
|
2568
3149
|
}
|
|
2569
3150
|
}
|
|
2570
3151
|
|
|
3152
|
+
// PROPOSAL-052 (c): read-only mop-up for the manifest-orphan edge. A
|
|
3153
|
+
// Dflow-generated bundle file that is no longer in the current package source
|
|
3154
|
+
// can linger if it was retired before generalized stale-removal shipped, or the
|
|
3155
|
+
// project was projected from a pre-release / non-registry source whose manifest
|
|
3156
|
+
// later forgot it. configure-agents only auto-removes files the manifest still
|
|
3157
|
+
// lists; a manifest-orphaned file (the manifest no longer lists it) must be
|
|
3158
|
+
// deleted by hand. Doctor detects and reports such files read-only (never
|
|
3159
|
+
// deletes). Detection requires a directory scan because, by definition, the
|
|
3160
|
+
// manifest no longer lists the orphan — but a read-only scan is safe here.
|
|
3161
|
+
async function checkOrphanedWorkflowBundleFiles(cwd, findings) {
|
|
3162
|
+
const bundleDir = path.join(cwd, WORKFLOW_BUNDLE_DEST);
|
|
3163
|
+
if (!(await pathExists(bundleDir))) return;
|
|
3164
|
+
|
|
3165
|
+
const edition = await inferProjectBundleEdition(cwd);
|
|
3166
|
+
if (!edition) return;
|
|
3167
|
+
|
|
3168
|
+
let sourceFiles;
|
|
3169
|
+
try {
|
|
3170
|
+
sourceFiles = await listBundleSourceFiles(edition);
|
|
3171
|
+
} catch {
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
const sourceRel = new Set(sourceFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
|
|
3175
|
+
|
|
3176
|
+
for (const dir of ['references', 'templates']) {
|
|
3177
|
+
const projectedDir = path.join(bundleDir, dir);
|
|
3178
|
+
let entries;
|
|
3179
|
+
try {
|
|
3180
|
+
entries = await fs.readdir(projectedDir);
|
|
3181
|
+
} catch {
|
|
3182
|
+
continue;
|
|
3183
|
+
}
|
|
3184
|
+
for (const entry of entries) {
|
|
3185
|
+
const rel = `${WORKFLOW_BUNDLE_DEST}/${dir}/${entry}`;
|
|
3186
|
+
if (sourceRel.has(rel)) continue;
|
|
3187
|
+
const abs = path.join(projectedDir, entry);
|
|
3188
|
+
const fileStat = await fs.stat(abs).catch(() => null);
|
|
3189
|
+
if (!fileStat || !fileStat.isFile()) continue;
|
|
3190
|
+
const content = await fs.readFile(abs, 'utf8').catch(() => '');
|
|
3191
|
+
if (!content.includes(WORKFLOW_BUNDLE_GENERATED_MARKER)) continue;
|
|
3192
|
+
findings.push({
|
|
3193
|
+
level: 'info',
|
|
3194
|
+
title: `Retired workflow bundle file: ${rel}`,
|
|
3195
|
+
detail: 'A Dflow-generated bundle file that is no longer part of the package source for this edition (a retired file left behind).',
|
|
3196
|
+
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.)'
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
// Infers the project's bundle edition for read-only checks: prefer the manifest
|
|
3203
|
+
// (authoritative for what was projected), fall back to project structure.
|
|
3204
|
+
async function inferProjectBundleEdition(cwd) {
|
|
3205
|
+
const manifestResult = await readCurrentBundleManifest(cwd);
|
|
3206
|
+
if (
|
|
3207
|
+
manifestResult.kind === 'ok' &&
|
|
3208
|
+
(manifestResult.manifest.edition === 'greenfield' || manifestResult.manifest.edition === 'brownfield')
|
|
3209
|
+
) {
|
|
3210
|
+
return manifestResult.manifest.edition;
|
|
3211
|
+
}
|
|
3212
|
+
return inferExistingEdition(cwd);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
2571
3215
|
function printDoctorReport(stdout, cwd, findings) {
|
|
2572
3216
|
stdout.write(`Dflow Doctor ${pkg.version}\n`);
|
|
2573
3217
|
stdout.write(`Project: ${cwd}\n\n`);
|
|
2574
3218
|
|
|
2575
3219
|
if (findings.length === 0) {
|
|
2576
|
-
stdout.write('All checks passed. No
|
|
3220
|
+
stdout.write('All checks passed. No Dflow health findings detected.\n');
|
|
2577
3221
|
return;
|
|
2578
3222
|
}
|
|
2579
3223
|
|
|
@@ -2595,5 +3239,14 @@ module.exports = {
|
|
|
2595
3239
|
runInit,
|
|
2596
3240
|
validateProseLanguage,
|
|
2597
3241
|
ensureProseLanguageSection,
|
|
2598
|
-
buildFilePlan
|
|
3242
|
+
buildFilePlan,
|
|
3243
|
+
// Exported for tests: the write phase enforces the PROPOSAL-054 raw-equality guard
|
|
3244
|
+
// for user-owned root agent files (changed-after-preview -> skip), which cannot be
|
|
3245
|
+
// exercised through the CLI because preview and write happen in one process.
|
|
3246
|
+
writeFilePlan,
|
|
3247
|
+
// Exported for tests (PROPOSAL-064): pure bundle-source guards, unit-tested on
|
|
3248
|
+
// synthetic descriptor lists without touching the packaged templates/ tree.
|
|
3249
|
+
assertNoBundleCollision,
|
|
3250
|
+
assertEditionBundleComplete,
|
|
3251
|
+
assertCommonBundleComplete
|
|
2599
3252
|
};
|