dflow-sdd-ddd 0.9.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.en.md +19 -8
  3. package/README.md +12 -5
  4. package/TEMPLATE-COVERAGE.md +0 -1
  5. package/bin/dflow.js +4 -4
  6. package/docs/evaluating-dflow.en.md +11 -7
  7. package/docs/evaluating-dflow.md +9 -4
  8. package/docs/migrating-to-dflow-v1.md +7 -3
  9. package/docs/using-with-claude-code.en.md +40 -23
  10. package/docs/using-with-claude-code.md +34 -23
  11. package/docs/using-with-codex.en.md +125 -42
  12. package/docs/using-with-codex.md +93 -34
  13. package/docs/using-with-github-copilot.en.md +135 -34
  14. package/docs/using-with-github-copilot.md +120 -43
  15. package/lib/init.js +761 -145
  16. package/package.json +2 -2
  17. package/templates/brownfield/references/drift-verification.md +1 -4
  18. package/templates/brownfield/references/finish-feature-flow.md +3 -2
  19. package/templates/brownfield/references/git-integration.md +0 -1
  20. package/templates/brownfield/references/init-project-flow.md +31 -17
  21. package/templates/brownfield/references/modify-existing-flow.md +6 -38
  22. package/templates/brownfield/references/new-feature-flow.md +13 -11
  23. package/templates/brownfield/references/new-phase-flow.md +1 -1
  24. package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +253 -2
  25. package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +5 -8
  26. package/templates/brownfield/scaffolding/_conventions.md +10 -9
  27. package/templates/brownfield/templates/_index.md +1 -1
  28. package/templates/brownfield/templates/lightweight-spec.md +1 -1
  29. package/templates/brownfield/templates/phase-spec.md +1 -1
  30. package/templates/common/skill/SKILL.md +9 -6
  31. package/templates/greenfield/references/drift-verification.md +1 -4
  32. package/templates/greenfield/references/finish-feature-flow.md +3 -2
  33. package/templates/greenfield/references/git-integration.md +0 -1
  34. package/templates/greenfield/references/init-project-flow.md +31 -17
  35. package/templates/greenfield/references/modify-existing-flow.md +5 -7
  36. package/templates/greenfield/references/new-feature-flow.md +14 -12
  37. package/templates/greenfield/references/new-phase-flow.md +1 -1
  38. package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +222 -2
  39. package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +10 -15
  40. package/templates/greenfield/scaffolding/Git-principles-trunk.md +1 -1
  41. package/templates/greenfield/scaffolding/_conventions.md +9 -8
  42. package/templates/greenfield/templates/_index.md +1 -1
  43. package/templates/greenfield/templates/lightweight-spec.md +1 -1
  44. package/templates/greenfield/templates/phase-spec.md +1 -1
  45. package/templates/brownfield/templates/CLAUDE.md +0 -165
  46. package/templates/greenfield/templates/CLAUDE.md +0 -172
package/lib/init.js CHANGED
@@ -15,6 +15,14 @@ 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';
@@ -236,7 +244,7 @@ async function runInit(options = {}) {
236
244
  const detection = await detectProjectSignals(cwd);
237
245
  const answers = await promptForAnswers(rl, stdout, stderr, detection);
238
246
  const plan = await buildFilePlan(cwd, answers);
239
- const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.bundleWarnings || [])];
247
+ const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection), ...(plan.warnings || []), ...(plan.bundleWarnings || [])];
240
248
 
241
249
  renderPreview(stdout, plan, warnings);
242
250
  const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
@@ -339,7 +347,8 @@ async function runConfigureAgents(options = {}) {
339
347
  result.warnings.push(...collectUnresolvedPlaceholderWarnings(plan, result.created));
340
348
 
341
349
  printResultReport(stdout, result, plan.deferred);
342
- printConfigureAgentsNextSteps(stdout, Boolean(options.commandAdapters));
350
+ const usedSnippetFallback = plan.items.some((item) => item.snippetFallback);
351
+ printConfigureAgentsNextSteps(stdout, Boolean(options.commandAdapters), usedSnippetFallback);
343
352
  return 0;
344
353
  } catch (error) {
345
354
  if (rl) {
@@ -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 (await pathExists(path.join(cwd, 'AGENTS.md'))) {
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 (await pathExists(path.join(cwd, '.github/copilot-instructions.md'))) {
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 Q6. Dflow init aborted.');
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';
@@ -1205,14 +1242,54 @@ async function listBundleSourceFiles(edition) {
1205
1242
  return files;
1206
1243
  }
1207
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.
1208
1252
  async function readCurrentBundleManifest(cwd) {
1209
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;
1210
1265
  try {
1211
- const raw = await fs.readFile(manifestPath, 'utf8');
1212
- return JSON.parse(raw);
1266
+ parsed = JSON.parse(raw);
1213
1267
  } catch {
1214
- return null;
1268
+ return { kind: 'corrupt', reason: 'manifest is not valid JSON' };
1215
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' };
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;
1216
1293
  }
1217
1294
 
1218
1295
  function buildBundleManifest(edition, version, files) {
@@ -1231,44 +1308,90 @@ function injectBundleMarker(content) {
1231
1308
  async function addWorkflowBundleItems(cwd, items, warnings, edition) {
1232
1309
  const bundleFiles = await listBundleSourceFiles(edition);
1233
1310
 
1234
- // Detect any previous manifest to handle edition-switch stale cleanup.
1235
- const existingManifest = await readCurrentBundleManifest(cwd);
1236
- const previousEdition = existingManifest ? existingManifest.edition : null;
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
+ }
1320
+
1321
+ const newRelPaths = new Set(bundleFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
1237
1322
 
1238
- // If the edition changed, schedule removal of stale generated files from prior edition.
1239
- if (previousEdition && previousEdition !== edition) {
1240
- const staleFiles = existingManifest.files || [];
1241
- for (const staleRelPath of staleFiles) {
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
+ }
1242
1362
  const staleAbsPath = path.join(cwd, staleRelPath);
1243
- let staleExists = false;
1363
+ let staleStat = null;
1244
1364
  try {
1245
- await fs.stat(staleAbsPath);
1246
- staleExists = true;
1365
+ staleStat = await fs.stat(staleAbsPath);
1247
1366
  } catch {
1248
- staleExists = false;
1367
+ staleStat = null;
1368
+ }
1369
+ if (!staleStat) {
1370
+ continue;
1249
1371
  }
1250
- if (!staleExists) {
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}`);
1251
1376
  continue;
1252
1377
  }
1253
1378
  const staleContent = await fs.readFile(staleAbsPath, 'utf8');
1254
1379
  if (!staleContent.includes(WORKFLOW_BUNDLE_GENERATED_MARKER)) {
1255
1380
  warnings.push(
1256
- `Edition changed from ${previousEdition} to ${edition}; skipped removal of user-modified bundle file: ${staleRelPath}`
1381
+ `Retired workflow bundle file is user-modified; left unchanged: ${staleRelPath}`
1257
1382
  );
1258
1383
  continue;
1259
1384
  }
1260
- // Check if this path is also in the new edition bundle — if so, it will be overwritten, not removed.
1261
- const newRelPaths = new Set(bundleFiles.map((f) => `${WORKFLOW_BUNDLE_DEST}/${f.sourceRel}`));
1262
- if (!newRelPaths.has(staleRelPath)) {
1263
- items.push({
1264
- relativePath: staleRelPath,
1265
- source: `stale-bundle:${previousEdition}`,
1266
- notes: `stale workflow bundle file from ${previousEdition} edition`,
1267
- action: 'remove',
1268
- size: Buffer.byteLength(staleContent, 'utf8'),
1269
- expectedContent: staleContent
1270
- });
1271
- }
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
+ });
1272
1395
  }
1273
1396
  }
1274
1397
 
@@ -1309,23 +1432,27 @@ async function addWorkflowBundleItems(cwd, items, warnings, edition) {
1309
1432
  });
1310
1433
  }
1311
1434
 
1312
- // Add the manifest file.
1313
- const manifestContent = JSON.stringify(
1314
- buildBundleManifest(edition, pkg.version, bundleFiles),
1315
- null,
1316
- 2
1317
- ) + '\n';
1318
- const manifestExists = await pathExists(path.join(cwd, WORKFLOW_BUNDLE_MANIFEST_PATH));
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));
1319
1445
 
1320
- items.push({
1321
- relativePath: WORKFLOW_BUNDLE_MANIFEST_PATH,
1322
- source: `generated:workflow-bundle-manifest`,
1323
- notes: 'workflow bundle manifest',
1324
- content: manifestContent,
1325
- action: manifestExists ? 'update' : 'create',
1326
- overwrite: true,
1327
- size: Buffer.byteLength(manifestContent, 'utf8')
1328
- });
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
+ }
1329
1456
  }
1330
1457
 
1331
1458
  async function readPackagedBundleFile(edition, sourceRel) {
@@ -1354,78 +1481,355 @@ async function readPackagedBundleFile(edition, sourceRel) {
1354
1481
  }
1355
1482
  }
1356
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).
1357
1498
  async function addAiAgentShim(cwd, items, agent, substitution, options = {}) {
1358
1499
  const target = getAiAgentTarget(agent);
1359
1500
  const targetPath = path.join(cwd, target.relativePath);
1360
- const targetExists = await pathExists(targetPath);
1361
- const targetConfigured = targetExists && await fileReferencesAiAgentGuide(targetPath);
1362
1501
  const commandRegistry = options.commandRegistry || [];
1363
1502
  const warnings = options.warnings;
1364
- const codexCommandAdapterSnippet = target.relativePath === 'AGENTS.md' && commandRegistry.length > 0 && targetExists;
1365
- const content = substitutePlaceholders(buildAiAgentShim(target.relativePath, options.commandRegistry), substitution);
1503
+ const isCodex = target.relativePath === 'AGENTS.md';
1504
+ const wantsTrigger = isCodex && commandRegistry.length > 0;
1366
1505
  const source = `generated:${agent}-shim`;
1367
1506
 
1368
- // PROPOSAL-046: when adding Codex command triggers and the existing AGENTS.md
1369
- // is an unmodified Dflow shim, inject the marked trigger section directly
1370
- // (zero manual merge) instead of parking a side snippet. A user-modified shim
1371
- // still degrades safely to the snippet + warning.
1372
- if (codexCommandAdapterSnippet && targetConfigured) {
1373
- const existingContent = await fs.readFile(targetPath, 'utf8');
1374
- const baseShim = substitutePlaceholders(buildAiAgentShim(target.relativePath), substitution);
1375
- if (isPristineDflowAgentsShim(existingContent, baseShim)) {
1376
- items.push({
1377
- relativePath: target.relativePath,
1378
- source,
1379
- notes: `selected, injected command trigger section into Dflow-generated ${target.relativePath}`,
1380
- content,
1381
- overwrite: true
1382
- });
1383
- return;
1384
- }
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;
1385
1578
  if (warnings) {
1386
1579
  warnings.push(
1387
- `Existing ${target.relativePath} was modified after Dflow generated it; wrote the command trigger section to dflow/specs/shared/AGENTS-md-command-adapters-snippet.md for manual merge.`
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.`
1388
1581
  );
1389
1582
  }
1390
1583
  items.push({
1391
- relativePath: 'dflow/specs/shared/AGENTS-md-command-adapters-snippet.md',
1584
+ relativePath: snippetPath,
1392
1585
  source,
1393
- notes: `selected, ${target.relativePath} was modified after Dflow generated it; merge this command trigger snippet manually`,
1394
- content,
1395
- overwrite: true
1586
+ notes: `selected, ${target.relativePath} has conflicting Dflow markers; merge this snippet manually`,
1587
+ content: snippetContent,
1588
+ overwrite: true,
1589
+ snippetFallback: true
1396
1590
  });
1397
1591
  return;
1398
1592
  }
1399
1593
 
1400
- const relativePath = codexCommandAdapterSnippet
1401
- ? target.snippetPath
1402
- : (targetExists && !targetConfigured ? target.snippetPath : target.relativePath);
1403
- let notes = 'selected, tool-specific shim';
1404
- if (codexCommandAdapterSnippet) {
1405
- notes = `selected, ${target.relativePath} already exists; merge this command trigger snippet manually`;
1406
- } else if (targetConfigured) {
1407
- notes = `selected, ${target.relativePath} already points to AI-AGENT-GUIDE.md`;
1408
- } else if (targetExists) {
1409
- notes = `selected, ${target.relativePath} already exists; merge this snippet manually`;
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;
1634
+ }
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;
1410
1661
  }
1411
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
+ }
1412
1695
  items.push({
1413
1696
  relativePath,
1414
1697
  source,
1415
1698
  notes,
1416
1699
  content,
1417
- overwrite: codexCommandAdapterSnippet
1700
+ expectedContent,
1701
+ action: 'update',
1702
+ overwrite: true,
1703
+ rootInject: true,
1704
+ size
1418
1705
  });
1419
1706
  }
1420
1707
 
1421
- async function fileReferencesAiAgentGuide(targetPath) {
1422
- try {
1423
- const content = await fs.readFile(targetPath, 'utf8');
1424
- return content.includes('dflow/specs/shared/AI-AGENT-GUIDE.md') ||
1425
- content.includes('dflow\\specs\\shared\\AI-AGENT-GUIDE.md');
1426
- } catch {
1427
- return false;
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`;
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;
1428
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;
1429
1833
  }
1430
1834
 
1431
1835
  function getAiAgentTarget(agent) {
@@ -1456,9 +1860,36 @@ function buildAiAgentShim(targetPath, commandRegistry = []) {
1456
1860
  ? buildCodexCommandTriggerSection(commandRegistry)
1457
1861
  : '';
1458
1862
 
1459
- const importHint = targetPath === 'CLAUDE.md'
1460
- ? '\nIf your tool supports Markdown imports, the canonical guide is imported below:\n\n@dflow/specs/shared/AI-AGENT-GUIDE.md\n'
1461
- : '';
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`;
1462
1893
 
1463
1894
  return `# ${title}
1464
1895
 
@@ -1471,7 +1902,7 @@ Before planning or editing code, read and follow:
1471
1902
 
1472
1903
  Keep tool-specific instruction files small. The guide and workflow bundle are
1473
1904
  the authoritative sources for Dflow workflow rules, slash-command behavior,
1474
- spec locations, and SDD/DDD constraints.${commandTriggerHint}${importHint}
1905
+ spec locations, and SDD/DDD constraints.
1475
1906
  `;
1476
1907
  }
1477
1908
 
@@ -1554,52 +1985,84 @@ async function buildDflowSkillAdapter() {
1554
1985
  return fs.readFile(sourcePath, 'utf8');
1555
1986
  }
1556
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
+
1557
2008
  async function addSkillAdapterItems(cwd, items, aiAgents, skills, warnings) {
1558
2009
  if (!skills) {
1559
2010
  return;
1560
2011
  }
1561
2012
 
1562
- if (!aiAgents.includes('claude')) {
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.
1563
2019
  warnings.push(
1564
- 'The --skills flag currently supports Claude Code only; no skill adapter was generated because Claude Code was not a selected target.'
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.'
1565
2021
  );
1566
2022
  return;
1567
2023
  }
1568
2024
 
1569
- const relativePath = '.claude/skills/dflow/SKILL.md';
1570
- const targetPath = path.join(cwd, relativePath);
1571
-
1572
2025
  // The thin skill is edition-neutral (it only points to the per-edition guide),
1573
2026
  // so there is nothing edition-specific to go stale — re-running just rewrites
1574
2027
  // the same marker-guarded file (idempotent). No LEGACY skill set exists yet
1575
2028
  // (skills are new in PROPOSAL-038); future skill cleanup would extend the same
1576
2029
  // LEGACY_* / addLegacyCommandAdapterCleanupItems marker-fingerprint pattern.
1577
- let existingContent;
1578
- try {
1579
- existingContent = await fs.readFile(targetPath, 'utf8');
1580
- } catch (error) {
1581
- if (error.code !== 'ENOENT') {
1582
- throw error;
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;
1583
2043
  }
1584
- existingContent = undefined;
1585
- }
1586
2044
 
1587
- if (existingContent !== undefined && !existingContent.includes(SKILL_ADAPTER_GENERATED_MARKER)) {
1588
- warnings.push(
1589
- 'Existing .claude/skills/dflow/SKILL.md is not a Dflow-generated skill; left unchanged. Remove or rename it to let Dflow manage this skill.'
1590
- );
1591
- return;
1592
- }
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
+ }
1593
2051
 
1594
- const skillContent = await buildDflowSkillAdapter();
1595
- items.push({
1596
- relativePath,
1597
- source: 'generated:claude-skill-adapter',
1598
- notes: 'skill adapter, thin skill pointing to AI-AGENT-GUIDE.md',
1599
- content: skillContent,
1600
- size: Buffer.byteLength(skillContent, 'utf8'),
1601
- overwrite: true
1602
- });
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
+ }
1603
2066
  }
1604
2067
 
1605
2068
  function buildLegacyCommandAdapterFingerprint(legacy, command) {
@@ -1647,16 +2110,43 @@ function stripCodexTriggerBlock(content) {
1647
2110
  return content.replace(re, '\n');
1648
2111
  }
1649
2112
 
1650
- // An AGENTS.md is a safely-injectable Dflow shim when, after removing any
1651
- // previously-injected trigger block, it matches the shim Dflow itself
1652
- // generates. This covers both a pristine 0.8.0/0.9.0 shim (no marker, normalized
1653
- // exact-template match) and a shim Dflow already injected into (idempotent
1654
- // re-projection). A user-edited shim fails the match and degrades to a snippet.
1655
- // (A future shim could carry its own generated-marker for a cheaper check, but
1656
- // that would require freezing this pre-marker template for back-compat matching;
1657
- // the normalized template match works for both eras without that.)
1658
- function isPristineDflowAgentsShim(existingContent, baseShim) {
1659
- return normalizeShimForMatch(stripCodexTriggerBlock(existingContent)) === normalizeShimForMatch(baseShim);
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));
1660
2150
  }
1661
2151
 
1662
2152
  function buildCodexCommandTriggerSection(commandRegistry) {
@@ -2240,7 +2730,7 @@ async function writeFilePlan(cwd, plan) {
2240
2730
  } catch (error) {
2241
2731
  if (error.code === 'ENOENT') {
2242
2732
  result.skipped.push(item.relativePath);
2243
- result.warnings.push(`Skipped missing stale adapter: ${item.relativePath}`);
2733
+ result.warnings.push(`Skipped missing stale file: ${item.relativePath}`);
2244
2734
  continue;
2245
2735
  }
2246
2736
  throw error;
@@ -2248,14 +2738,14 @@ async function writeFilePlan(cwd, plan) {
2248
2738
 
2249
2739
  if (!stats.isFile()) {
2250
2740
  result.skipped.push(item.relativePath);
2251
- result.warnings.push(`Skipped stale adapter removal because target is not a file: ${item.relativePath}`);
2741
+ result.warnings.push(`Skipped stale removal because target is not a file: ${item.relativePath}`);
2252
2742
  continue;
2253
2743
  }
2254
2744
 
2255
2745
  const currentContent = await fs.readFile(targetPath, 'utf8');
2256
2746
  if (normalizeCommandAdapterFingerprint(currentContent) !== normalizeCommandAdapterFingerprint(item.expectedContent || '')) {
2257
2747
  result.skipped.push(item.relativePath);
2258
- result.warnings.push(`Skipped stale adapter removal because content changed after preview: ${item.relativePath}`);
2748
+ result.warnings.push(`Skipped stale removal because content changed after preview: ${item.relativePath}`);
2259
2749
  continue;
2260
2750
  }
2261
2751
 
@@ -2264,6 +2754,45 @@ async function writeFilePlan(cwd, plan) {
2264
2754
  continue;
2265
2755
  }
2266
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
+
2267
2796
  if (await pathExists(targetPath)) {
2268
2797
  if (item.overwrite) {
2269
2798
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
@@ -2273,7 +2802,12 @@ async function writeFilePlan(cwd, plan) {
2273
2802
  }
2274
2803
 
2275
2804
  result.skipped.push(item.relativePath);
2276
- result.warnings.push(`Skipped existing target: ${item.relativePath}`);
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
+ }
2277
2811
  if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
2278
2812
  result.warnings.push(
2279
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.'
@@ -2282,6 +2816,15 @@ async function writeFilePlan(cwd, plan) {
2282
2816
  continue;
2283
2817
  }
2284
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
+
2285
2828
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
2286
2829
  await fs.writeFile(targetPath, item.content, { flag: 'wx' });
2287
2830
 
@@ -2396,20 +2939,25 @@ Recommended next steps:
2396
2939
  `);
2397
2940
  }
2398
2941
 
2399
- function printConfigureAgentsNextSteps(stdout, commandAdapters = false) {
2942
+ function printConfigureAgentsNextSteps(stdout, commandAdapters = false, snippetFallback = false) {
2400
2943
  const commandAdapterStep = commandAdapters
2401
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'
2402
2945
  : '';
2403
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
+
2404
2954
  stdout.write(`
2405
2955
  Dflow AI agent configuration complete.
2406
2956
 
2407
2957
  Recommended next steps:
2408
2958
  - Keep AI-agent-specific root files small.
2409
2959
  - Put durable workflow changes in dflow/specs/shared/AI-AGENT-GUIDE.md.
2410
- - If a merge snippet was created, review it and merge the pointer into the existing tool instruction file.
2411
- ${commandAdapterStep}
2412
- `);
2960
+ ${snippetStep}${commandAdapterStep}`);
2413
2961
  }
2414
2962
 
2415
2963
  function printList(stdout, values) {
@@ -2511,6 +3059,7 @@ async function runDoctor(options = {}) {
2511
3059
  await checkLegacyRootSpecsDir(cwd, findings);
2512
3060
  await checkLegacySharedDir(cwd, findings);
2513
3061
  await checkConventionsDflowVersion(cwd, findings);
3062
+ await checkOrphanedWorkflowBundleFiles(cwd, findings);
2514
3063
 
2515
3064
  printDoctorReport(stdout, cwd, findings);
2516
3065
  return 0;
@@ -2568,6 +3117,69 @@ async function checkConventionsDflowVersion(cwd, findings) {
2568
3117
  }
2569
3118
  }
2570
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
+
2571
3183
  function printDoctorReport(stdout, cwd, findings) {
2572
3184
  stdout.write(`Dflow Doctor ${pkg.version}\n`);
2573
3185
  stdout.write(`Project: ${cwd}\n\n`);
@@ -2595,5 +3207,9 @@ module.exports = {
2595
3207
  runInit,
2596
3208
  validateProseLanguage,
2597
3209
  ensureProseLanguageSection,
2598
- 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
2599
3215
  };