devflow-kit 3.3.0 → 3.4.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 (138) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/agents/code.md +330 -0
  3. package/{src/assets → dist}/agents/design.md +1 -1
  4. package/{src/assets → dist}/agents/diagnose.md +1 -2
  5. package/dist/agents/git.md +29 -56
  6. package/{src/assets → dist}/agents/knowledge.md +4 -3
  7. package/{src/assets → dist}/agents/research.md +2 -2
  8. package/{src/assets → dist}/agents/review.md +8 -7
  9. package/{src/assets → dist}/agents/scrutinize.md +1 -1
  10. package/dist/agents/skim.md +148 -0
  11. package/{src/assets → dist}/agents/triage.md +1 -1
  12. package/dist/cli/commands/init.js +62 -0
  13. package/dist/cli/commands/learning.js +38 -3
  14. package/dist/cli/commands/uninstall.js +42 -1
  15. package/dist/commands/bug-analysis.md +30 -8
  16. package/dist/commands/code-review.md +141 -60
  17. package/dist/commands/debug.md +14 -12
  18. package/dist/commands/dynamic-build.md +37 -38
  19. package/dist/commands/dynamic-plan.md +30 -18
  20. package/dist/commands/dynamic-profile.md +27 -13
  21. package/dist/commands/dynamic-tickets.md +28 -14
  22. package/dist/commands/explore.md +15 -13
  23. package/dist/commands/implement.md +33 -28
  24. package/dist/commands/plan.md +37 -24
  25. package/dist/commands/release.md +69 -4
  26. package/dist/commands/research.md +33 -11
  27. package/dist/commands/resolve.md +35 -32
  28. package/dist/commands/self-review.md +36 -23
  29. package/dist/core/agent-models.js +43 -0
  30. package/dist/core/assets.js +55 -10
  31. package/dist/core/claude-md-audit.js +190 -0
  32. package/dist/core/feature-switch.js +20 -1
  33. package/dist/core/flags.js +28 -0
  34. package/dist/core/fs-atomic.js +8 -3
  35. package/dist/core/learning-variants.js +213 -0
  36. package/dist/core/manifest.js +62 -0
  37. package/dist/core/mds-variants.js +38 -1
  38. package/dist/core/plugins.js +71 -9
  39. package/{src/assets → dist/learning-off}/agents/code.md +6 -10
  40. package/dist/learning-off/agents/design.md +119 -0
  41. package/dist/learning-off/agents/diagnose.md +210 -0
  42. package/dist/learning-off/agents/knowledge.md +90 -0
  43. package/dist/learning-off/agents/research.md +149 -0
  44. package/dist/learning-off/agents/review.md +228 -0
  45. package/dist/learning-off/agents/scrutinize.md +117 -0
  46. package/{src/assets → dist/learning-off}/agents/skim.md +1 -8
  47. package/dist/learning-off/agents/triage.md +163 -0
  48. package/dist/learning-off/commands/bug-analysis.md +420 -0
  49. package/dist/learning-off/commands/code-review.md +525 -0
  50. package/dist/learning-off/commands/debug.md +294 -0
  51. package/dist/learning-off/commands/dynamic-build.md +1255 -0
  52. package/dist/learning-off/commands/dynamic-plan.md +424 -0
  53. package/dist/learning-off/commands/dynamic-profile.md +214 -0
  54. package/dist/learning-off/commands/dynamic-tickets.md +632 -0
  55. package/dist/learning-off/commands/explore.md +210 -0
  56. package/dist/learning-off/commands/implement.md +808 -0
  57. package/dist/learning-off/commands/plan.md +664 -0
  58. package/dist/learning-off/commands/release.md +310 -0
  59. package/dist/learning-off/commands/research.md +222 -0
  60. package/dist/learning-off/commands/resolve.md +837 -0
  61. package/dist/learning-off/commands/self-review.md +266 -0
  62. package/dist/skills/git/references/tracker/_contract.md +33 -0
  63. package/dist/skills/git/references/tracker/github/fetch-issue.md +2 -0
  64. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +2 -0
  65. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +4 -0
  66. package/dist/skills/git/references/tracker/github/post-wave-report.md +2 -0
  67. package/dist/skills/git/references/tracker/github/setup-task.md +12 -0
  68. package/dist/skills/git/references/tracker/jira/associate-release.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/fetch-issue.md +2 -0
  70. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +2 -0
  71. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +4 -0
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +2 -0
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +14 -2
  74. package/dist/skills/git/references/tracker/linear/associate-release.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/fetch-issue.md +2 -0
  76. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +2 -0
  77. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +4 -0
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +2 -0
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +14 -2
  80. package/dist/targets/claude-code/installer.js +72 -36
  81. package/dist/targets/claude-code/language-stamp.js +185 -0
  82. package/dist/targets/claude-code/learning-install.js +489 -0
  83. package/package.json +1 -1
  84. package/src/assets/agents/code.mds +339 -0
  85. package/src/assets/agents/design.mds +149 -0
  86. package/src/assets/agents/diagnose.mds +225 -0
  87. package/src/assets/agents/evaluate.md +1 -3
  88. package/src/assets/agents/git.mds +29 -56
  89. package/src/assets/agents/knowledge.mds +125 -0
  90. package/src/assets/agents/research.mds +176 -0
  91. package/src/assets/agents/review.mds +286 -0
  92. package/src/assets/agents/scrutinize.mds +132 -0
  93. package/src/assets/agents/skim.mds +161 -0
  94. package/src/assets/agents/triage.mds +194 -0
  95. package/src/assets/agents/validate.md +8 -6
  96. package/src/assets/commands/_partials/_compliance.mds +5 -4
  97. package/src/assets/commands/_partials/_decisions.mds +31 -0
  98. package/src/assets/commands/_partials/_engine.mds +9 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +25 -12
  100. package/src/assets/commands/_partials/_preamble.mds +33 -9
  101. package/src/assets/commands/_partials/_publication.mds +5 -4
  102. package/src/assets/commands/_partials/_settings.mds +13 -5
  103. package/src/assets/commands/_partials/_wave.mds +8 -0
  104. package/src/assets/commands/bug-analysis.mds +24 -2
  105. package/src/assets/commands/code-review.mds +147 -44
  106. package/src/assets/commands/debug.mds +17 -1
  107. package/src/assets/commands/dynamic-build.mds +33 -2
  108. package/src/assets/commands/dynamic-plan.mds +36 -6
  109. package/src/assets/commands/dynamic-profile.mds +9 -1
  110. package/src/assets/commands/dynamic-tickets.mds +16 -2
  111. package/src/assets/commands/explore.mds +27 -1
  112. package/src/assets/commands/implement.mds +41 -8
  113. package/src/assets/commands/plan.mds +47 -8
  114. package/src/assets/commands/{release.md → release.mds} +27 -24
  115. package/src/assets/commands/research.mds +28 -4
  116. package/src/assets/commands/resolve.mds +43 -2
  117. package/src/assets/commands/self-review.mds +30 -5
  118. package/src/assets/mds/tracker/_contract.mds +72 -0
  119. package/src/assets/mds/tracker/_github.mds +13 -2
  120. package/src/assets/mds/tracker/_jira.mds +17 -5
  121. package/src/assets/mds/tracker/_linear.mds +17 -5
  122. package/src/assets/mds/tracker/_mcp.mds +2 -2
  123. package/src/assets/mds/tracker/_steps.mds +97 -0
  124. package/src/assets/rules/context-economy.md +10 -0
  125. package/src/assets/rules/go.md +1 -0
  126. package/src/assets/rules/java.md +1 -0
  127. package/src/assets/rules/python.md +1 -0
  128. package/src/assets/rules/rust.md +1 -0
  129. package/src/assets/rules/typescript.md +1 -0
  130. package/src/assets/scripts/claude-md-audit.cjs +611 -0
  131. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +1 -2
  132. package/src/assets/scripts/hooks/json-helper.cjs +13 -5
  133. package/src/assets/scripts/hooks/json-parse +34 -10
  134. package/src/assets/scripts/hooks/session-start-context +315 -7
  135. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  136. package/src/assets/skills/apply-feature-knowledge/SKILL.md +5 -5
  137. package/src/assets/skills/feature-knowledge/SKILL.md +43 -12
  138. package/src/assets/skills/quality-gates/SKILL.md +1 -1
@@ -1,13 +1,14 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { existsSync } from 'fs';
3
3
  import * as path from 'path';
4
- import { SKILL_NAMESPACE, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan } from '../../core/plugins.js';
5
- import { skillsDir, agentSourceDirs, rulesDir, commandsDir, scriptsDir, compiledSkillRefsDir } from '../../core/assets.js';
4
+ import { SKILL_NAMESPACE, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitLearningGatedSkills } from '../../core/plugins.js';
5
+ import { skillsDir, agentSourceDirs, rulesDir, commandSourceDirs, scriptsDir, compiledSkillRefsDir } from '../../core/assets.js';
6
6
  import { getPackageRoot, isContainedIn } from '../../core/paths.js';
7
7
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
8
8
  import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT } from '../../core/mds-variants.js';
9
9
  import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
10
10
  import { TRACKER_AGENT_NAME } from './tracker-install.js';
11
+ import { restampInstalledCommands } from './language-stamp.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Shadow validation helpers (exported — reused by Step 4 list commands)
13
14
  // ---------------------------------------------------------------------------
@@ -1278,6 +1279,39 @@ async function firstExisting(candidates) {
1278
1279
  }
1279
1280
  return undefined;
1280
1281
  }
1282
+ /**
1283
+ * Resolve the directory a registry skill installs from: its valid shadow, else
1284
+ * the shipped source (an invalid shadow is reported and the source is
1285
+ * installed in its place).
1286
+ *
1287
+ * The ONE spelling of that decision: the install loop and
1288
+ * convergeLearningVariants both call it, so a skill the learning toggle installs
1289
+ * later is shadowed exactly as one the install installed.
1290
+ *
1291
+ * The shipped source is stat-checked FIRST, even under a valid shadow, so an
1292
+ * absent source is a packaging failure that throws (the hard-error policy for a
1293
+ * declared source) rather than being masked by a shadow. Callers that must not
1294
+ * throw catch it.
1295
+ *
1296
+ * @param packageRoot - Package root the shipped source is read from. Injectable so
1297
+ * a caller working on a temp tree resolves the skill from the same root as its
1298
+ * other sources; omitted, it is the running package.
1299
+ */
1300
+ export async function resolveSkillSource(skillName, devflowDir, packageRoot) {
1301
+ const skillSource = path.join(skillsDir(packageRoot), skillName);
1302
+ let isDir = false;
1303
+ try {
1304
+ isDir = (await fs.stat(skillSource)).isDirectory();
1305
+ }
1306
+ catch { /* stat failed — source absent */ }
1307
+ if (!isDir) {
1308
+ throw new Error(`Skill source not found for declared skill "${skillName}": ${skillSource}. ` +
1309
+ `Ensure the skill directory exists in src/assets/skills/.`);
1310
+ }
1311
+ const shadowDir = path.join(devflowDir, 'skills', skillName);
1312
+ const shadow = await validateSkillShadow(shadowDir);
1313
+ return { dir: shadow === 'valid' ? shadowDir : skillSource, shadow };
1314
+ }
1281
1315
  /**
1282
1316
  * Registry skills that have a shadow directory under `~/.devflow/skills/`.
1283
1317
  *
@@ -1347,7 +1381,7 @@ async function removeDeselectedSkills(skillsDir, names) {
1347
1381
  * Returns an InstallReport describing which shadows were applied and which were skipped.
1348
1382
  */
1349
1383
  export async function installViaFileCopy(options) {
1350
- const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, warn = () => { }, } = options;
1384
+ const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, learning, warn = () => { }, } = options;
1351
1385
  const report = {
1352
1386
  shadowedSkills: [],
1353
1387
  shadowedRules: [],
@@ -1484,11 +1518,13 @@ export async function installViaFileCopy(options) {
1484
1518
  warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
1485
1519
  }
1486
1520
  // Install commands from selected plugins using registry-driven lookup.
1487
- // Source: dist/commands/{name}.md (single lookup directory for all commands).
1521
+ // Source: dist/commands/{name}.md, or — with learning off — the learning-off
1522
+ // variant dist/learning-off/commands/{name}.md when the host has one
1523
+ // (D-LEARNING-VARIANT-INSTALL; commandSourceDirs owns the order).
1488
1524
  // A declared command with no compiled source file is a hard error, not a skip.
1489
1525
  spinner.message('Installing commands and agents...');
1490
1526
  const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1491
- const cDir = commandsDir();
1527
+ const commandDirs = commandSourceDirs(learning);
1492
1528
  const commandsSourceNames = new Set();
1493
1529
  for (const plugin of plugins) {
1494
1530
  for (const cmd of plugin.commands) {
@@ -1499,12 +1535,11 @@ export async function installViaFileCopy(options) {
1499
1535
  if (commandsSourceNames.size > 0) {
1500
1536
  await fs.mkdir(commandsTarget, { recursive: true });
1501
1537
  for (const name of commandsSourceNames) {
1502
- const srcFile = path.join(cDir, mdFileName(name));
1503
- try {
1504
- await fs.access(srcFile);
1505
- }
1506
- catch {
1507
- throw new Error(`Command source not found for declared command "${name}": ${srcFile}. ` +
1538
+ const srcFile = await firstExisting(commandDirs.map(dir => path.join(dir, mdFileName(name))));
1539
+ if (srcFile === undefined) {
1540
+ // The error names the learning-ON path: that file exists for every declared
1541
+ // command, while a learning-off file exists only for a host with an arm.
1542
+ throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(true)[0], mdFileName(name))}. ` +
1508
1543
  `Ensure build:mds ran successfully before install.`);
1509
1544
  }
1510
1545
  await fs.copyFile(srcFile, path.join(commandsTarget, mdFileName(name)));
@@ -1514,6 +1549,18 @@ export async function installViaFileCopy(options) {
1514
1549
  // knownNames spans ALL plugins (getAllCommandNames) so commands from uninstalled
1515
1550
  // plugins survive a partial run. Only names absent from the full registry are removed.
1516
1551
  recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames()), mdEntryName));
1552
+ // D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
1553
+ // installed /code-review, on every install shape. This is the language gate the command reads
1554
+ // (no run-time probe of Claude Code's directory), so it runs after the copy and the sweep and
1555
+ // whether or not code-review's own plugin is in this run: `--plugin=devflow-typescript` copies no
1556
+ // command, yet must update a code-review an earlier run installed. The copy above wrote the shipped
1557
+ // `(none)` line; the rewrite compares against what is on disk now, so neither that copy nor the
1558
+ // pre-clean can defeat it. A failure is a warning, never an abort.
1559
+ await restampInstalledCommands({
1560
+ claudeDir,
1561
+ effectivePlugins: options.effectivePlugins ?? plugins,
1562
+ warn,
1563
+ });
1517
1564
  // Install agents (deduplicated), resolved dist-first with a src fallback:
1518
1565
  // dist/agents/{name}.md (compiled from an .mds generator host) wins over
1519
1566
  // src/assets/agents/{name}.md. A declared agent absent from BOTH is a
@@ -1532,7 +1579,8 @@ export async function installViaFileCopy(options) {
1532
1579
  // registry via getAllAgentNames() — still treats a converged tracker.md as known
1533
1580
  // and leaves it alone.
1534
1581
  const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1535
- const agentDirs = options.agentSourceDirs ?? agentSourceDirs();
1582
+ // Learning-aware (D-LEARNING-VARIANT-INSTALL). An injected list wins as given.
1583
+ const agentDirs = options.agentSourceDirs ?? agentSourceDirs(undefined, learning);
1536
1584
  const allAgentNames = new Set();
1537
1585
  for (const plugin of plugins) {
1538
1586
  for (const agent of plugin.agents) {
@@ -1549,7 +1597,7 @@ export async function installViaFileCopy(options) {
1549
1597
  const candidates = agentDirs.map(dir => path.join(dir, mdFileName(agentName)));
1550
1598
  const srcFile = await firstExisting(candidates);
1551
1599
  if (srcFile === undefined) {
1552
- throw new Error(`Agent source not found for declared agent "${agentName}": ${candidates[0]}. ` +
1600
+ throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(options.agentSourceDirs?.[0] ?? agentSourceDirs()[0], mdFileName(agentName))}. ` +
1553
1601
  `Run \`npm run build:mds\` if it is compiled from an .mds generator host, otherwise ` +
1554
1602
  `ensure the agent file exists in src/assets/agents/ (searched: ${candidates.join(', ')}).`);
1555
1603
  }
@@ -1564,33 +1612,21 @@ export async function installViaFileCopy(options) {
1564
1612
  // Resolved from flat src/assets/skills/{name}/ (no per-plugin subdirectory).
1565
1613
  // A declared skill whose source directory is absent is a build/packaging failure
1566
1614
  // and throws rather than silently skipping (matches command pattern).
1615
+ //
1616
+ // D-LEARNING-VARIANT-INSTALL: a learning-off machine skips the learning-gated
1617
+ // skills here. The pre-clean above walked the UNFILTERED map, so on a full
1618
+ // install a leftover copy is already gone; a partial install leaves removal to
1619
+ // convergeLearningVariants, which init runs straight after this call.
1567
1620
  spinner.message('Installing skills...');
1568
- for (const [skillName] of skillsMap) {
1569
- const skillSource = path.join(skillsDir(), skillName);
1570
- let isDir = false;
1571
- try {
1572
- const stat = await fs.stat(skillSource);
1573
- isDir = stat.isDirectory();
1574
- }
1575
- catch { /* stat failed — source absent */ }
1576
- if (!isDir) {
1577
- throw new Error(`Skill source not found for declared skill "${skillName}": ${skillSource}. ` +
1578
- `Ensure the skill directory exists in src/assets/skills/.`);
1579
- }
1580
- const shadowDir = path.join(devflowDir, 'skills', skillName);
1581
- const prefixedName = prefixSkillName(skillName);
1582
- const skillTarget = path.join(claudeDir, 'skills', prefixedName);
1583
- const shadowState = await validateSkillShadow(shadowDir);
1584
- if (shadowState === 'valid') {
1585
- await copyDirectory(shadowDir, skillTarget);
1621
+ for (const [skillName] of omitLearningGatedSkills(skillsMap, learning)) {
1622
+ const resolved = await resolveSkillSource(skillName, devflowDir);
1623
+ const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
1624
+ await copyDirectory(resolved.dir, skillTarget);
1625
+ if (resolved.shadow === 'valid') {
1586
1626
  report.shadowedSkills.push(skillName);
1587
1627
  }
1588
- else if (shadowState === 'missing-skill-md') {
1628
+ else if (resolved.shadow === 'missing-skill-md') {
1589
1629
  report.skippedShadows.push({ kind: 'skill', name: skillName, reason: 'missing-skill-md' });
1590
- await copyDirectory(skillSource, skillTarget);
1591
- }
1592
- else {
1593
- await copyDirectory(skillSource, skillTarget);
1594
1630
  }
1595
1631
  // Converge the generated references onto the skill that was just installed. One call
1596
1632
  // site downstream of all three branches above, so a shadowed devflow:git receives the
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Language-focus stamp for the installed /code-review command.
3
+ *
4
+ * D-LANGUAGE-FOCUS-STAMP: /code-review spawns a language focus only when its file-type trigger fires AND
5
+ * the focus appears on one fixed-prefix line of the installed command. That line replaces the run-time
6
+ * `test -f` probe of Claude Code's directory; the installer writes it from the registry and the effective
7
+ * selection ({@link installedLanguageFocuses}), so the prompt reads a fact instead of discovering one.
8
+ * Decided here:
9
+ *
10
+ * - The carrier is one line, `Installed language focuses: <list>`, shipped as `(none)` in
11
+ * src/assets/commands/code-review.mds and found again by an anchored match, so a later run can
12
+ * re-stamp a copy an earlier run stamped. It carries no MDS interpolation braces, and it sits outside
13
+ * every learning arm, so both variants carry it. A separate stamped file would add a file to the
14
+ * install walk and the install goldens, which an in-place rewrite leaves unchanged.
15
+ * - Only code-review.md carries it. /dynamic-build spawns every review focus and drops none (plan
16
+ * decision 1), so it has no stamp, no reduced classes and no DIFF_FILE.
17
+ * - The stamp describes what is on disk, so it is computed from the EFFECTIVE selection
18
+ * (`FileCopyOptions.effectivePlugins`), never from the plugins one run copies, and it is applied after
19
+ * the command copy and the orphan sweep, whether or not code-review's own plugin is in the run. The
20
+ * rewrite compares the bytes it is about to write with the bytes now on disk, at the moment it writes,
21
+ * so no earlier same-run copy or pre-clean can defeat it.
22
+ * - `devflow uninstall --plugin` removes a language plugin's skill but no command, so its name would
23
+ * stay in the stamped copy; the selective phase re-stamps from the plugins that remain, through
24
+ * {@link restampInstalledCommands} like the install.
25
+ * - Composition with the learning converge (D-LEARNING-VARIANT-INSTALL), pinned: the converge rewrites
26
+ * installed command files from dist or dist/learning-off, and those files carry the shipped `(none)`
27
+ * line. It therefore applies {@link carryLanguageStamp} inside its own write path, putting the stamp
28
+ * it finds on the installed copy into the variant it writes ({@link stampForConverge}); a copy with no
29
+ * list to carry, one installed before the stamp existed, is stamped from the converge's selection
30
+ * instead of being given the shipped `(none)`. The alternative, re-stamping after every
31
+ * converge write, would make a steady-state run see a stamped copy that differs from its source, rewrite
32
+ * it and re-stamp it on every `devflow init`; carrying keeps the converge's byte comparison honest, so a
33
+ * run that changes nothing writes nothing, and it leaves one authority for the list: install and
34
+ * uninstall change it, a variant switch never does.
35
+ * - No prompt rule for a damaged stamp exists: no legitimate configuration produces one. A copy with
36
+ * no stamp line, or several, is reported here as a warning and left as it is.
37
+ * - Nothing here decides a diff's class or which focuses a diff gets. Those are prompt rules the
38
+ * orchestrator executes; this module only turns the registry and a selection into a list.
39
+ *
40
+ * Never throws: every failure is a warning, because the install must not die on a stamp.
41
+ */
42
+ import { promises as fs } from 'fs';
43
+ import * as path from 'path';
44
+ import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
45
+ import { mdFileName } from '../../core/orphan-sweep.js';
46
+ import { installedLanguageFocuses } from '../../core/plugins.js';
47
+ /** The fixed prefix of the stamp line; the prompt and the anchored match share it. */
48
+ export const LANGUAGE_STAMP_PREFIX = 'Installed language focuses: ';
49
+ /** What an empty list renders as, and what the build ships. */
50
+ export const LANGUAGE_STAMP_NONE = '(none)';
51
+ /** Installed commands that carry the stamp line. Only /code-review (plan decision 1). */
52
+ export const LANGUAGE_STAMPED_COMMANDS = ['code-review'];
53
+ /** The most focuses one stamp lists; the registry has eight, so the bound is generous. */
54
+ const MAX_STAMPED_FOCUSES = 16;
55
+ /** One focus name is a plain lowercase skill name. */
56
+ const FOCUS_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
57
+ /** A well-formed list as rendered: `(none)`, or comma-space separated plain names. */
58
+ const STAMP_VALUE_RE = /^(?:\(none\)|[a-z][a-z0-9-]{0,31}(?:, [a-z][a-z0-9-]{0,31}){0,15})$/;
59
+ /** The stamp line, anchored to a whole line. Rebuilt per use: a global regex carries state. */
60
+ const stampLineRe = () => /^Installed language focuses: [^\r\n]*$/gm;
61
+ /** The full stamp line for a list. */
62
+ export function renderLanguageStamp(focuses) {
63
+ return `${LANGUAGE_STAMP_PREFIX}${focuses.length === 0 ? LANGUAGE_STAMP_NONE : focuses.join(', ')}`;
64
+ }
65
+ /**
66
+ * `content` with its one stamp line replaced by the rendering of `focuses`.
67
+ *
68
+ * Pure. Refuses a list holding anything but plain skill names, so no prompt text can ride in on it, and
69
+ * refuses a file with no stamp line or several rather than guessing which one is the carrier.
70
+ */
71
+ export function applyLanguageStamp(content, focuses) {
72
+ if (focuses.length > MAX_STAMPED_FOCUSES || !focuses.every(name => FOCUS_NAME_RE.test(name))) {
73
+ return { ok: false, reason: 'bad-focus-name' };
74
+ }
75
+ const lines = [...content.matchAll(stampLineRe())];
76
+ if (lines.length === 0)
77
+ return { ok: false, reason: 'no-stamp-line' };
78
+ if (lines.length > 1)
79
+ return { ok: false, reason: 'several-stamp-lines' };
80
+ const at = lines[0].index ?? 0;
81
+ return { ok: true, content: content.slice(0, at) + renderLanguageStamp(focuses) + content.slice(at + lines[0][0].length) };
82
+ }
83
+ /**
84
+ * `source` with its stamp line replaced by the one on `installed`.
85
+ *
86
+ * The learning converge's write path (see the header): the variant it installs keeps the list the
87
+ * install stamped. Returns `source` unchanged unless both sides hold exactly one stamp line and the
88
+ * installed one is a well-formed list, so a damaged or hostile line is never copied into a fresh file.
89
+ */
90
+ export function carryLanguageStamp(source, installed) {
91
+ const carried = carriableStampLine(installed);
92
+ const into = [...source.matchAll(stampLineRe())];
93
+ if (carried === null || into.length !== 1)
94
+ return source;
95
+ const at = into[0].index ?? 0;
96
+ return source.slice(0, at) + carried + source.slice(at + into[0][0].length);
97
+ }
98
+ /** The installed copy's one stamp line when it holds exactly one and its list is well formed, else null. */
99
+ function carriableStampLine(installed) {
100
+ const from = [...installed.matchAll(stampLineRe())];
101
+ if (from.length !== 1)
102
+ return null;
103
+ return STAMP_VALUE_RE.test(from[0][0].slice(LANGUAGE_STAMP_PREFIX.length)) ? from[0][0] : null;
104
+ }
105
+ /**
106
+ * The text the learning converge installs for a stamped command: `source` carrying the installed copy's
107
+ * list ({@link carryLanguageStamp}) when the copy holds one, else `source` stamped with `focuses`, the
108
+ * effective selection's list, exactly as the install stamps it.
109
+ *
110
+ * A copy with no list to carry was installed by a version before the stamp existed, or is damaged. A
111
+ * `--plugin` init right after an upgrade reaches such a copy through the converge alone (the run copies
112
+ * no command), so installing the shipped `(none)` over it would drop every language focus until the next
113
+ * full install. A list that IS carried is never changed here. Pure.
114
+ */
115
+ export function stampForConverge(source, installed, focuses) {
116
+ if (carriableStampLine(installed) !== null)
117
+ return carryLanguageStamp(source, installed);
118
+ const stamped = applyLanguageStamp(source, focuses);
119
+ return stamped.ok ? stamped.content : source;
120
+ }
121
+ /**
122
+ * Stamp every installed copy that carries the stamp with the list for `effectivePlugins`.
123
+ *
124
+ * Called by installViaFileCopy after the command copy and the orphan sweep (every install shape) and by
125
+ * the selective uninstall phase after the removals. A command that is not installed is left absent, so a
126
+ * `--plugin` run never gains a command it did not install.
127
+ */
128
+ export async function restampInstalledCommands(opts) {
129
+ const { claudeDir, effectivePlugins, warn, mayChange } = opts;
130
+ const focuses = installedLanguageFocuses(effectivePlugins);
131
+ const rewritten = [];
132
+ const unchanged = [];
133
+ const absent = [];
134
+ const failed = [];
135
+ // Precondition, asserted in production code: a relative claudeDir would resolve the targets
136
+ // against the working directory.
137
+ if (!path.isAbsolute(claudeDir)) {
138
+ warn(`language stamp: claudeDir is not an absolute path ("${claudeDir}") — the installed commands were not stamped`);
139
+ return { focuses, rewritten, unchanged, absent, failed: [...LANGUAGE_STAMPED_COMMANDS] };
140
+ }
141
+ for (const command of LANGUAGE_STAMPED_COMMANDS) {
142
+ const target = path.join(claudeDir, 'commands', 'devflow', mdFileName(command));
143
+ let current;
144
+ try {
145
+ current = await fs.readFile(target, 'utf-8');
146
+ }
147
+ catch (err) {
148
+ if (err.code === 'ENOENT') {
149
+ absent.push(command);
150
+ }
151
+ else {
152
+ warn(`language stamp: cannot read ${mdFileName(command)} (${target}) — ${String(err)}`);
153
+ failed.push(command);
154
+ }
155
+ continue;
156
+ }
157
+ const stamped = applyLanguageStamp(current, focuses);
158
+ if (!stamped.ok) {
159
+ warn(stamped.reason === 'no-stamp-line'
160
+ ? `language stamp: ${mdFileName(command)} has no stamp line, so the installed language focuses were not recorded. Run \`devflow init\` to reinstall it.`
161
+ : `language stamp: ${mdFileName(command)} has ${stamped.reason === 'several-stamp-lines' ? 'several stamp lines' : 'an unusable language list'}, so it was left as it is. Run \`devflow init\` to reinstall it.`);
162
+ failed.push(command);
163
+ continue;
164
+ }
165
+ // Compared at the moment of writing, against what is on disk now.
166
+ if (stamped.content === current) {
167
+ unchanged.push(command);
168
+ continue;
169
+ }
170
+ try {
171
+ if (mayChange !== undefined && !(await mayChange(target))) {
172
+ failed.push(command);
173
+ continue;
174
+ }
175
+ await writeFileAtomicExclusive(target, stamped.content);
176
+ rewritten.push(command);
177
+ }
178
+ catch (err) {
179
+ warn(`language stamp: could not rewrite ${mdFileName(command)} (${target}) — ${String(err)}`);
180
+ failed.push(command);
181
+ }
182
+ }
183
+ return { focuses, rewritten, unchanged, absent, failed };
184
+ }
185
+ //# sourceMappingURL=language-stamp.js.map