devflow-kit 3.8.0 → 3.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 (211) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +6 -6
  3. package/dist/agents/code.md +10 -10
  4. package/dist/agents/fix.md +7 -7
  5. package/dist/agents/git.md +2 -2
  6. package/dist/agents/harden.md +7 -6
  7. package/dist/agents/refactor.md +6 -6
  8. package/dist/agents/skim.md +1 -1
  9. package/dist/agents/triage.md +7 -0
  10. package/dist/cli/commands/ambient.js +10 -2
  11. package/dist/cli/commands/compliance-prompts.js +9 -4
  12. package/dist/cli/commands/compliance.js +171 -24
  13. package/dist/cli/commands/curation.js +79 -23
  14. package/dist/cli/commands/gate-level-prompts.js +39 -23
  15. package/dist/cli/commands/gates.js +6 -7
  16. package/dist/cli/commands/init.js +150 -145
  17. package/dist/cli/commands/install-report.js +34 -0
  18. package/dist/cli/commands/legacy-sweep.js +64 -0
  19. package/dist/cli/commands/memory.js +59 -12
  20. package/dist/cli/commands/proxy.js +6 -1
  21. package/dist/cli/commands/rules.js +29 -12
  22. package/dist/cli/commands/skills.js +42 -9
  23. package/dist/cli/commands/tracker.js +9 -3
  24. package/dist/cli/commands/uninstall.js +354 -60
  25. package/dist/commands/bug-analysis.md +71 -122
  26. package/dist/commands/code-review.md +11 -11
  27. package/dist/commands/debug.md +2 -2
  28. package/dist/commands/dynamic-build.md +25 -25
  29. package/dist/commands/dynamic-plan.md +2 -2
  30. package/dist/commands/dynamic-profile.md +2 -2
  31. package/dist/commands/dynamic-tickets.md +2 -2
  32. package/dist/commands/explore.md +2 -2
  33. package/dist/commands/implement.md +48 -34
  34. package/dist/commands/plan.md +2 -2
  35. package/dist/commands/release.md +3 -3
  36. package/dist/commands/research.md +2 -2
  37. package/dist/commands/resolve.md +50 -46
  38. package/dist/commands/self-review.md +10 -9
  39. package/dist/compliance-off/agents/git.md +120 -0
  40. package/dist/compliance-off/agents/triage.md +180 -0
  41. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  42. package/dist/compliance-off/commands/code-review.md +520 -0
  43. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  44. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  45. package/dist/compliance-off/commands/implement.md +736 -0
  46. package/dist/compliance-off/commands/plan.md +671 -0
  47. package/dist/compliance-off/commands/release.md +309 -0
  48. package/dist/compliance-off/commands/resolve.md +742 -0
  49. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  50. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  51. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  52. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  53. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  54. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  55. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  56. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  57. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  58. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  59. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  60. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  61. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  62. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  63. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  64. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  65. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  66. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  67. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  68. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  69. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  70. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  71. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  72. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  73. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  74. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  75. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  76. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  77. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  78. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  79. package/dist/core/agent-models.js +19 -4
  80. package/dist/core/area-pause-notice.js +44 -0
  81. package/dist/core/assets.js +126 -17
  82. package/dist/core/compliance-off-files.js +27 -0
  83. package/dist/core/curation-move-skip.js +4 -5
  84. package/dist/core/curation-queue-cleanup.js +6 -5
  85. package/dist/core/curation-store.js +1 -0
  86. package/dist/core/curation-variants.js +460 -112
  87. package/dist/core/evidence-policy.js +10 -8
  88. package/dist/core/flags.js +2 -2
  89. package/dist/core/installed-variants.js +63 -0
  90. package/dist/core/manifest.js +48 -5
  91. package/dist/core/mds-variants.js +700 -3
  92. package/dist/core/orphan-sweep.js +11 -2
  93. package/dist/core/plugins.js +69 -12
  94. package/dist/core/tracked-areas.js +138 -14
  95. package/dist/curation-compliance-off/agents/triage.md +176 -0
  96. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  97. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  98. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  99. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  100. package/dist/curation-compliance-off/commands/implement.md +689 -0
  101. package/dist/curation-compliance-off/commands/plan.md +631 -0
  102. package/dist/curation-compliance-off/commands/release.md +279 -0
  103. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  104. package/dist/curation-off/agents/code.md +10 -10
  105. package/dist/curation-off/agents/fix.md +7 -7
  106. package/dist/curation-off/agents/harden.md +7 -6
  107. package/dist/curation-off/agents/refactor.md +6 -6
  108. package/dist/curation-off/agents/triage.md +7 -0
  109. package/dist/curation-off/commands/bug-analysis.md +69 -119
  110. package/dist/curation-off/commands/code-review.md +11 -11
  111. package/dist/curation-off/commands/debug.md +2 -2
  112. package/dist/curation-off/commands/dynamic-build.md +25 -25
  113. package/dist/curation-off/commands/explore.md +2 -2
  114. package/dist/curation-off/commands/implement.md +48 -34
  115. package/dist/curation-off/commands/plan.md +2 -2
  116. package/dist/curation-off/commands/release.md +1 -1
  117. package/dist/curation-off/commands/resolve.md +50 -46
  118. package/dist/curation-off/commands/self-review.md +10 -9
  119. package/dist/hud/components/curation-counts.js +27 -0
  120. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  121. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  122. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  123. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  124. package/dist/skills/git/references/publication-gate.md +6 -7
  125. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  126. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  127. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  128. package/dist/targets/claude-code/behavior-install.js +23 -9
  129. package/dist/targets/claude-code/charter-render.js +374 -0
  130. package/dist/targets/claude-code/compliance-install.js +32 -12
  131. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  132. package/dist/targets/claude-code/curation-install.js +570 -100
  133. package/dist/targets/claude-code/installer.js +467 -62
  134. package/dist/targets/claude-code/language-stamp.js +6 -0
  135. package/dist/targets/claude-code/linked-folder.js +182 -0
  136. package/dist/targets/claude-code/reference-converge.js +118 -0
  137. package/dist/targets/claude-code/tracker-install.js +21 -8
  138. package/dist/targets/claude-code/tracker-servers.js +20 -6
  139. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  140. package/dist/variant-axes.json +109 -0
  141. package/package.json +1 -1
  142. package/src/assets/agents/_partials/_code.mds +6 -6
  143. package/src/assets/agents/code.mds +4 -4
  144. package/src/assets/agents/evaluate.md +7 -13
  145. package/src/assets/agents/fix.mds +1 -1
  146. package/src/assets/agents/git.mds +34 -2
  147. package/src/assets/agents/harden.mds +7 -6
  148. package/src/assets/agents/skim.mds +1 -1
  149. package/src/assets/agents/test.md +21 -12
  150. package/src/assets/agents/triage.mds +9 -0
  151. package/src/assets/agents/validate.md +27 -34
  152. package/src/assets/commands/_partials/_compliance.mds +5 -3
  153. package/src/assets/commands/_partials/_decisions.mds +10 -8
  154. package/src/assets/commands/_partials/_engine.mds +9 -8
  155. package/src/assets/commands/_partials/_publication.mds +10 -1
  156. package/src/assets/commands/_partials/_settings.mds +2 -2
  157. package/src/assets/commands/bug-analysis.mds +80 -126
  158. package/src/assets/commands/code-review.mds +52 -9
  159. package/src/assets/commands/dynamic-build.mds +109 -15
  160. package/src/assets/commands/dynamic-tickets.mds +13 -0
  161. package/src/assets/commands/implement.mds +145 -33
  162. package/src/assets/commands/plan.mds +19 -0
  163. package/src/assets/commands/release.mds +50 -1
  164. package/src/assets/commands/resolve.mds +229 -44
  165. package/src/assets/commands/self-review.mds +8 -7
  166. package/src/assets/mds/git/_ops.mds +29 -1
  167. package/src/assets/mds/git/_ops_pr.mds +20 -10
  168. package/src/assets/mds/git/_ops_steps.mds +27 -0
  169. package/src/assets/mds/git/_pr.mds +15 -15
  170. package/src/assets/mds/git/_references.mds +12 -7
  171. package/src/assets/mds/tracker/_common.mds +4 -0
  172. package/src/assets/mds/tracker/_github.mds +43 -4
  173. package/src/assets/mds/tracker/_jira.mds +44 -4
  174. package/src/assets/mds/tracker/_linear.mds +44 -4
  175. package/src/assets/rules/quality.md +2 -0
  176. package/src/assets/scripts/ci-wait.cjs +493 -118
  177. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  178. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  179. package/src/assets/scripts/hooks/background-memory-update +79 -1
  180. package/src/assets/scripts/hooks/json-helper.cjs +44 -2
  181. package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
  182. package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
  183. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
  184. package/src/assets/scripts/hooks/memory-permission +10 -2
  185. package/src/assets/scripts/hooks/memory-worker +11 -0
  186. package/src/assets/scripts/hooks/pre-compact-memory +11 -0
  187. package/src/assets/scripts/hooks/preamble +18 -8
  188. package/src/assets/scripts/hooks/queue-append +218 -1
  189. package/src/assets/scripts/hooks/session-start-context +89 -37
  190. package/src/assets/scripts/hooks/session-start-memory +39 -0
  191. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  192. package/src/assets/scripts/pr-evidence.cjs +46 -4
  193. package/src/assets/scripts/resolve-settings.cjs +244 -30
  194. package/src/assets/scripts/verify-evidence.cjs +708 -124
  195. package/src/assets/skills/agent-references/SKILL.md +7 -1
  196. package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
  197. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  198. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  199. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
  200. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  201. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  202. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  203. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  204. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  205. package/src/assets/skills/git/SKILL.md +3 -3
  206. package/src/assets/skills/git/references/github-api.md +2 -139
  207. package/src/assets/skills/git/references/github-posting.md +158 -0
  208. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  209. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  210. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  211. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -44,6 +44,7 @@ import * as path from 'path';
44
44
  import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
45
45
  import { mdFileName } from '../../core/orphan-sweep.js';
46
46
  import { installedLanguageFocuses } from '../../core/plugins.js';
47
+ import { isUnrealFolder } from './linked-folder.js';
47
48
  /** The fixed prefix of the stamp line; the prompt and the anchored match share it. */
48
49
  export const LANGUAGE_STAMP_PREFIX = 'Installed language focuses: ';
49
50
  /** What an empty list renders as, and what the build ships. */
@@ -138,6 +139,11 @@ export async function restampInstalledCommands(opts) {
138
139
  warn(`language stamp: claudeDir is not an absolute path ("${claudeDir}") — the installed commands were not stamped`);
139
140
  return { focuses, rewritten, unchanged, absent, failed: [...LANGUAGE_STAMPED_COMMANDS] };
140
141
  }
142
+ // Whatever the caller's guard says, nothing is read or written through a commands folder that is a
143
+ // link or not a folder; every stamped command is then left as it is, and `failed` says so.
144
+ if (await isUnrealFolder(path.join(claudeDir, 'commands', 'devflow'))) {
145
+ return { focuses, rewritten, unchanged, absent, failed: [...LANGUAGE_STAMPED_COMMANDS] };
146
+ }
141
147
  for (const command of LANGUAGE_STAMPED_COMMANDS) {
142
148
  const target = path.join(claudeDir, 'commands', 'devflow', mdFileName(command));
143
149
  let current;
@@ -0,0 +1,182 @@
1
+ import { promises as fs } from 'fs';
2
+ import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
3
+ /** Classify `dir` with `lstat`, so a link is seen as a link and never as the directory it points to. */
4
+ export async function directoryState(dir) {
5
+ try {
6
+ const stat = await fs.lstat(dir);
7
+ if (stat.isSymbolicLink())
8
+ return 'link';
9
+ return stat.isDirectory() ? 'real' : 'unusable';
10
+ }
11
+ catch (err) {
12
+ return err.code === 'ENOENT' ? 'absent' : 'unusable';
13
+ }
14
+ }
15
+ /** Whether `dir` is a link or not a folder, so that a run must not write into it. An absent folder is fine: the run creates it. */
16
+ export async function isUnrealFolder(dir) {
17
+ const state = await directoryState(dir);
18
+ return state === 'link' || state === 'unusable';
19
+ }
20
+ /** Whether a link stands at `entry`. An absent or unreadable entry is not a link. */
21
+ export async function entryIsLink(entry) {
22
+ return (await directoryState(entry)) === 'link';
23
+ }
24
+ /**
25
+ * Remove the link at `entry` with `unlink`: the link, never what it points to.
26
+ *
27
+ * The one place the pre-clean replaces a link. `readdir` and `mkdir` follow a link, so a pre-clean
28
+ * that emptied through one would empty another directory. Never throws; a link that cannot be
29
+ * removed is `stuck`, and the caller leaves the path alone.
30
+ */
31
+ export async function unlinkLinkAt(entry) {
32
+ if (!(await entryIsLink(entry)))
33
+ return 'no-link';
34
+ try {
35
+ await fs.unlink(entry);
36
+ return 'unlinked';
37
+ }
38
+ catch {
39
+ return 'stuck';
40
+ }
41
+ }
42
+ /**
43
+ * Remove `target` whole. A link there is unlinked instead and returned, so the caller can report it;
44
+ * the folder it points to is left as it is. A failure of the final `rm` is thrown.
45
+ */
46
+ export async function removeReportingLink(target) {
47
+ const outcome = await unlinkLinkAt(target);
48
+ if (outcome === 'no-link')
49
+ await fs.rm(target, { recursive: true, force: true });
50
+ return outcome === 'unlinked' ? [target] : [];
51
+ }
52
+ /**
53
+ * Prove, with {@link isUnrealFolder}, that a run may write into `dir`, and record the folder in
54
+ * `report.refusedFolders` when it may not. Returns true for a refused folder, so the caller skips
55
+ * every write into it.
56
+ *
57
+ * The answer for each folder a run writes into besides the `devflow:git` references, which the
58
+ * installer's `referencesHostState` answers: the commands, rules and agents folders and every other
59
+ * skill folder. An absent folder is fine, since the install creates it. A link is refused and never
60
+ * repaired, because the installer did not create it. A full install's pre-clean has already replaced
61
+ * the links it could, so this refuses only what is left. Never throws.
62
+ */
63
+ export async function refuseUnrealFolder(report, dir) {
64
+ if (!(await isUnrealFolder(dir)))
65
+ return false;
66
+ report.refusedFolders.push(dir);
67
+ return true;
68
+ }
69
+ /**
70
+ * Make `entry` safe to write: a link there is unlinked, never followed, so the caller then creates a
71
+ * real file or folder in its place. The entry can be a file or a folder. Only the last component is
72
+ * checked, so a caller checks each folder on the way down before it checks an entry in it.
73
+ *
74
+ * Fail closed: an entry `lstat` cannot inspect is refused. Never throws.
75
+ */
76
+ export async function clearLinkedEntry(entry) {
77
+ try {
78
+ const stat = await fs.lstat(entry);
79
+ if (!stat.isSymbolicLink())
80
+ return 'clear';
81
+ }
82
+ catch (err) {
83
+ return err.code === 'ENOENT' ? 'clear' : 'refused';
84
+ }
85
+ try {
86
+ await fs.unlink(entry);
87
+ return 'replaced';
88
+ }
89
+ catch {
90
+ return 'refused';
91
+ }
92
+ }
93
+ /**
94
+ * {@link clearLinkedEntry} plus the report: a replaced link goes to `report.replacedLinks`, a refused
95
+ * entry to `report.refusedFolders`. Returns true when the caller may write at `entry`.
96
+ *
97
+ * The one call a write step makes before it creates or overwrites a file or folder inside a Devflow
98
+ * folder. Omit `report` when the step has none; the link is still never written through.
99
+ */
100
+ export async function clearLinkForWrite(entry, report) {
101
+ const clearance = await clearLinkedEntry(entry);
102
+ if (clearance === 'replaced')
103
+ report?.replacedLinks.push(entry);
104
+ if (clearance === 'refused')
105
+ report?.refusedFolders.push(entry);
106
+ return clearance !== 'refused';
107
+ }
108
+ /** A report that holds no link yet. */
109
+ export function newLinkReport() {
110
+ return { replacedLinks: [], refusedFolders: [] };
111
+ }
112
+ /**
113
+ * One report from several: the paths of every report in order, each path once. A command that gets
114
+ * the links of more than one step prints this, so a folder that two steps met is named once.
115
+ * Returns a new report; the inputs are not changed.
116
+ */
117
+ export function mergeLinkReports(...reports) {
118
+ return {
119
+ replacedLinks: [...new Set(reports.flatMap(report => report.replacedLinks))],
120
+ refusedFolders: [...new Set(reports.flatMap(report => report.refusedFolders))],
121
+ };
122
+ }
123
+ /**
124
+ * The line for a link a step replaced. One wording for the install summary and for every converge
125
+ * that reports through a `warn` callback.
126
+ */
127
+ export function replacedLinkMessage(linkPath) {
128
+ return `${linkPath} was a link. It was replaced with a real file or folder, and what it pointed to was left as it was.`;
129
+ }
130
+ /**
131
+ * The first sentence of every refused-folder line: what the folder is. The install and the uninstall
132
+ * both open with it and then say what they did and what to do.
133
+ */
134
+ export function refusedFolderStatement(folder) {
135
+ return `${folder} is a link or not a folder.`;
136
+ }
137
+ /**
138
+ * The line for a folder a step wrote nothing at, because it is a link it could not remove or not a
139
+ * folder. One wording for the install summary and for every converge that reports through a `warn`
140
+ * callback.
141
+ */
142
+ export function refusedFolderMessage(folder) {
143
+ return `${refusedFolderStatement(folder)} Nothing was written there. Replace it with a real file or folder and run the install again.`;
144
+ }
145
+ /** Send one line per link in `report` to `warn`: the replaced links first, then the refused folders. */
146
+ export function warnLinkReport(report, warn) {
147
+ for (const linkPath of report.replacedLinks)
148
+ warn(replacedLinkMessage(linkPath));
149
+ for (const folder of report.refusedFolders)
150
+ warn(refusedFolderMessage(folder));
151
+ }
152
+ /** The mode of a file that replaces a link. The atomic writer would copy the mode of the link's target instead. */
153
+ const REPLACED_LINK_FILE_MODE = 0o644;
154
+ /**
155
+ * Write `content` at `file` through a temp file beside it and one rename. A rename puts the new file
156
+ * in the place of whatever stands at `file`, so a link there is replaced and never followed; the link
157
+ * is recorded in `links.replacedLinks` once the write is done. A file that replaces a link has the
158
+ * fixed mode 0644, never the mode of what the link pointed to, which the atomic writer would copy
159
+ * because its `stat` follows the link. Any other file goes through the atomic writer and keeps its
160
+ * own mode. The caller has proved that the folder holding `file` is a real one. Throws what the
161
+ * writer throws, so the caller keeps its own catch.
162
+ */
163
+ export async function writeFileReplacingLink(file, content, links) {
164
+ if (!(await entryIsLink(file))) {
165
+ await writeFileAtomicExclusive(file, content);
166
+ return;
167
+ }
168
+ const temp = `${file}.tmp.${process.pid}`;
169
+ try {
170
+ // A stale temp file is removed first; the exclusive open then refuses anything placed there since.
171
+ await fs.rm(temp, { force: true });
172
+ await fs.writeFile(temp, content, { encoding: 'utf-8', flag: 'wx', mode: REPLACED_LINK_FILE_MODE });
173
+ await fs.chmod(temp, REPLACED_LINK_FILE_MODE);
174
+ await fs.rename(temp, file);
175
+ }
176
+ catch (err) {
177
+ await fs.rm(temp, { force: true }).catch(() => undefined);
178
+ throw err;
179
+ }
180
+ links?.replacedLinks.push(file);
181
+ }
182
+ //# sourceMappingURL=linked-folder.js.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The references kind of the variant converge: make the installed `devflow:git` references
3
+ * match the machine's compliance switch (AC-41, AC-50).
4
+ *
5
+ * `devflow init`, `devflow compliance` and `devflow curation` reach it through
6
+ * convergeVariantAxes, so a compliance toggle needs no reinstall. It does, in order:
7
+ *
8
+ * 1. the overlay, with the manifest and the ONE source root of the switch
9
+ * (`installedReferenceManifest({ compliance })`, `skillRefsSourceDir`): it rewrites the
10
+ * references whose text differs between the two states and prunes the omitted
11
+ * operation files from `pr/` and `tracker/`. With compliance off the source root is the
12
+ * compliance-off tree, and an absent root is a failure that writes nothing: the converge
13
+ * never falls through to the compliance-on references;
14
+ * 2. with compliance off, the removal list ({@link removeComplianceOffFiles});
15
+ * with compliance on, the restore of the files the skill copy puts in place
16
+ * ({@link restoreComplianceOffFiles}), from the source the install would copy.
17
+ *
18
+ * It acts on a skill that is ALREADY installed. A machine whose plugin selection holds no
19
+ * `devflow:git` has nothing to converge, and that is a clean result. A skill directory, or a
20
+ * `references` directory inside it, that is a link or not a directory is a failure: the overlay
21
+ * would stage, prune and set modes through it. A full install empties the skill directory first and
22
+ * so replaces such a link; this converge and a partial install have no such step, so each refuses
23
+ * instead, on the same proof ({@link referencesHostState}).
24
+ *
25
+ * Never throws. Every failure is a warning and a `failures` entry, and the next step still
26
+ * runs. With compliance off a caller reads `failures` and `stillPosting` and fails closed.
27
+ */
28
+ import * as path from 'path';
29
+ import { skillRefsSourceDir } from '../../core/assets.js';
30
+ import { installedReferenceManifest, SKILL_REFS_SKILL_NAME } from '../../core/mds-variants.js';
31
+ import { getPackageRoot } from '../../core/paths.js';
32
+ import { prefixSkillName } from '../../core/plugins.js';
33
+ import { removeComplianceOffFiles, restoreComplianceOffFiles } from './compliance-off-files.js';
34
+ import { overlayGeneratedReferences, overlayUnitLabel, referencesHostState, resolveSkillSource } from './installer.js';
35
+ export async function convergeReferences(opts) {
36
+ const { claudeDir, devflowDir, compliance, warn } = opts;
37
+ const failures = [];
38
+ const fail = (message) => {
39
+ warn(`reference converge: ${message}`);
40
+ failures.push(message);
41
+ };
42
+ // Precondition, asserted in production code: a relative claudeDir would resolve every
43
+ // install target somewhere unexpected, the removals included.
44
+ if (!path.isAbsolute(claudeDir)) {
45
+ fail(`claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
46
+ return { converged: false, rewritten: [], removed: [], restored: [], failures, stillPosting: [], unproven: !compliance };
47
+ }
48
+ const root = opts.packageRoot ?? getPackageRoot();
49
+ const skillsDir = path.join(claudeDir, 'skills');
50
+ const gitSkillDir = path.join(skillsDir, prefixSkillName(SKILL_REFS_SKILL_NAME));
51
+ const manifest = installedReferenceManifest({ compliance });
52
+ let rewritten = [];
53
+ let unproven = false;
54
+ const host = await referencesHostState(gitSkillDir);
55
+ if (host.kind === 'refused') {
56
+ fail(`${host.path} is not a real directory, so the references in it were not converged`);
57
+ unproven = true;
58
+ }
59
+ else if (host.kind === 'ready') {
60
+ try {
61
+ const overlay = await overlayGeneratedReferences({
62
+ referencesTarget: path.join(gitSkillDir, 'references'),
63
+ sourceRoot: skillRefsSourceDir(compliance, root),
64
+ manifest,
65
+ warn,
66
+ });
67
+ rewritten = overlay.overlaidRefs;
68
+ for (const failure of overlay.overlayFailures) {
69
+ fail(`${overlayUnitLabel(failure.unit)} was not refreshed (${failure.error})`);
70
+ unproven = true;
71
+ }
72
+ for (const failed of overlay.pruned.failed) {
73
+ fail(`could not prune ${failed.name} (${String(failed.error)})`);
74
+ unproven = true;
75
+ }
76
+ }
77
+ catch (err) {
78
+ // The overlay throws for a packaging failure only: an absent source root, or a manifest entry
79
+ // the root lacks. Nothing is written in either case, and nothing falls through to another root.
80
+ fail(err instanceof Error ? err.message : String(err));
81
+ unproven = true;
82
+ }
83
+ }
84
+ const removed = [];
85
+ const restored = [];
86
+ const stillPosting = [];
87
+ if (!compliance) {
88
+ const removal = await removeComplianceOffFiles(skillsDir);
89
+ removed.push(...removal.removed);
90
+ for (const refusal of removal.refused) {
91
+ fail(`${refusal.entry} was not removed — ${refusal.reason}`);
92
+ stillPosting.push(path.join(skillsDir, ...refusal.entry.split('/')));
93
+ }
94
+ }
95
+ else {
96
+ // The overlay owns every generated reference (publication-gate.md among them), so the restore
97
+ // writes back only the files that come from the skill source.
98
+ const ownedByOverlay = new Set(manifest.map(rel => `${prefixSkillName(SKILL_REFS_SKILL_NAME)}/references/${rel}`));
99
+ const restoration = await restoreComplianceOffFiles({
100
+ skillsDir,
101
+ sourceDirOf: async (skill) => (await resolveSkillSource(skill, devflowDir, opts.packageRoot)).dir,
102
+ ownedElsewhere: ownedByOverlay,
103
+ });
104
+ restored.push(...restoration.restored);
105
+ for (const refusal of restoration.refused)
106
+ fail(`${refusal.entry} was not restored — ${refusal.reason}`);
107
+ }
108
+ return {
109
+ converged: failures.length === 0,
110
+ rewritten,
111
+ removed,
112
+ restored,
113
+ failures,
114
+ stillPosting: compliance ? [] : stillPosting,
115
+ unproven: compliance ? false : unproven,
116
+ };
117
+ }
118
+ //# sourceMappingURL=reference-converge.js.map
@@ -15,6 +15,7 @@ import { stampAgentTools } from '../../core/agent-frontmatter.js';
15
15
  import { agentSourceDirs } from '../../core/assets.js';
16
16
  import { mdFileName } from '../../core/orphan-sweep.js';
17
17
  import { TRACKER_MCP_SERVERS_DEFAULT, trackerServerTools } from '../../core/tracker.js';
18
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
18
19
  /**
19
20
  * The agent this module owns.
20
21
  *
@@ -97,55 +98,67 @@ async function writeWouldChangeNothing(wanted, target) {
97
98
  * nothing reports `unchanged` and the summary stays quiet
98
99
  * (see {@link writeWouldChangeNothing}).
99
100
  *
101
+ * An `agents/devflow` that is a link, or not a folder, is refused: nothing is written there, and the
102
+ * folder is named in `refusedFolders` and in a warning. A `tracker.md` that is a link is replaced by a
103
+ * real file, and named in `replacedLinks` and in a warning.
104
+ *
100
105
  * Never throws. A caller reads `converged`; it does not catch. The one refusal
101
106
  * that is not an I/O degradation — a claudeDir that is not absolute — is reported
102
107
  * the same way rather than thrown, because an install must not die on it.
103
108
  */
104
109
  export async function convergeTrackerArtifacts(opts) {
105
110
  const { claudeDir, warn } = opts;
111
+ const links = newLinkReport();
112
+ const finish = (converged, agent) => {
113
+ warnLinkReport(links, warn);
114
+ return { converged, agent, replacedLinks: links.replacedLinks, refusedFolders: links.refusedFolders };
115
+ };
106
116
  // Precondition, asserted in production code rather than only in tests: an
107
117
  // empty or relative claudeDir would make the target resolve somewhere
108
118
  // unexpected.
109
119
  if (!path.isAbsolute(claudeDir)) {
110
120
  warn(`tracker: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
111
- return { converged: false, agent: 'unchanged' };
121
+ return finish(false, 'unchanged');
112
122
  }
113
123
  const target = agentTarget(claudeDir);
124
+ // A link at the folder sends the write into another place, so the folder is proven first.
125
+ if (await refuseUnrealFolder(links, path.dirname(target)))
126
+ return finish(false, 'unchanged');
114
127
  const dirs = opts.agentSourceDirs ?? agentSourceDirs();
115
128
  const candidates = dirs.map(dir => path.join(dir, mdFileName(TRACKER_AGENT_NAME)));
116
129
  const source = await firstExisting(candidates);
117
130
  if (source === undefined) {
118
131
  warn(`tracker: agent source not found for "${TRACKER_AGENT_NAME}" (searched: ${candidates.join(', ')}) — ` +
119
132
  `run \`npm run build:mds\` if it is compiled from an .mds generator host`);
120
- return { converged: false, agent: 'unchanged' };
133
+ return finish(false, 'unchanged');
121
134
  }
122
135
  let wanted;
123
136
  try {
124
137
  const stamped = stampAgentTools(await fs.readFile(source, 'utf-8'), trackerServerTools(opts.servers ?? TRACKER_MCP_SERVERS_DEFAULT));
125
138
  if (!stamped.ok) {
126
139
  warn(`tracker: the Tracker agent source could not take the tracker servers (${stamped.error}) — not installed`);
127
- return { converged: false, agent: 'unchanged' };
140
+ return finish(false, 'unchanged');
128
141
  }
129
142
  wanted = stamped.value.content;
130
143
  }
131
144
  catch (err) {
132
145
  warn(`tracker: cannot read the Tracker agent source (${source}) — ${String(err)}`);
133
- return { converged: false, agent: 'unchanged' };
146
+ return finish(false, 'unchanged');
134
147
  }
135
148
  // Already converged — nothing to write, and nothing for the summary to announce.
136
149
  // The directory is not created either: an identical file at the target means it is
137
150
  // already there (see {@link writeWouldChangeNothing}).
138
151
  if (await writeWouldChangeNothing(wanted, target)) {
139
- return { converged: true, agent: 'unchanged' };
152
+ return finish(true, 'unchanged');
140
153
  }
141
154
  try {
142
155
  await fs.mkdir(path.dirname(target), { recursive: true });
143
- await fs.writeFile(target, wanted);
156
+ await writeFileReplacingLink(target, wanted, links);
144
157
  }
145
158
  catch (err) {
146
159
  warn(`tracker: failed to install the Tracker agent (${target}) — ${String(err)}`);
147
- return { converged: false, agent: 'unchanged' };
160
+ return finish(false, 'unchanged');
148
161
  }
149
- return { converged: true, agent: 'installed' };
162
+ return finish(true, 'installed');
150
163
  }
151
164
  //# sourceMappingURL=tracker-install.js.map
@@ -17,7 +17,7 @@
17
17
  * a `--plugin` run that does not install Git leaves the installed, stamped copy alone.
18
18
  * 2. `convergeTrackerArtifacts` stamps Tracker as it writes it, and compares the STAMPED text
19
19
  * with the installed file, so a steady-state run reports `unchanged`.
20
- * 3. `convergeCurationVariants` (init, `devflow curation --enable/--disable`) carries the
20
+ * 3. `convergeVariantAxes` (init, `devflow curation` and `devflow compliance`) carries the
21
21
  * installed stamp onto the variant it writes (`carryStampedTools`), the way it carries
22
22
  * `model:` and `effort:`, so a variant switch keeps the list and a repeat run is byte-equal.
23
23
  * 4. `devflow tracker --servers` re-stamps the installed copies at once ({@link restampTrackerAgents}).
@@ -34,9 +34,9 @@
34
34
  import { promises as fs } from 'fs';
35
35
  import * as path from 'path';
36
36
  import { stampAgentTools } from '../../core/agent-frontmatter.js';
37
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
38
37
  import { mdFileName } from '../../core/orphan-sweep.js';
39
38
  import { trackerServerTools } from '../../core/tracker.js';
39
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
40
40
  import { TRACKER_AGENT_NAME } from './tracker-install.js';
41
41
  /** The registry name of the Git agent. */
42
42
  export const GIT_AGENT_NAME = 'git';
@@ -51,7 +51,10 @@ export function isTrackerStampedAgent(agentName) {
51
51
  *
52
52
  * Rewrites only an installed copy (an absent one stays absent), compares against the bytes on
53
53
  * disk at the moment of writing, and writes atomically only when they differ, so a run that
54
- * changes nothing writes nothing. Never throws.
54
+ * changes nothing writes nothing. An `agents/devflow` that is a link, or not a folder, is refused:
55
+ * nothing is written there, both agents are `failed`, and the folder is named in `refusedFolders` and
56
+ * in a warning. An agent file that is a link is replaced by a real file, and named in `replacedLinks`
57
+ * and in a warning. Never throws.
55
58
  */
56
59
  export async function restampTrackerAgents(opts) {
57
60
  const { claudeDir, servers, warn } = opts;
@@ -59,11 +62,22 @@ export async function restampTrackerAgents(opts) {
59
62
  const unchanged = [];
60
63
  const absent = [];
61
64
  const failed = [];
65
+ const links = newLinkReport();
66
+ const finish = () => {
67
+ warnLinkReport(links, warn);
68
+ return { rewritten, unchanged, absent, failed, replacedLinks: links.replacedLinks, refusedFolders: links.refusedFolders };
69
+ };
62
70
  // Precondition, asserted in production code: a relative claudeDir would resolve the targets
63
71
  // against the working directory.
64
72
  if (!path.isAbsolute(claudeDir)) {
65
73
  warn(`tracker servers: claudeDir is not an absolute path ("${claudeDir}") — the installed agents were not stamped`);
66
- return { rewritten, unchanged, absent, failed: [...TRACKER_STAMPED_AGENTS] };
74
+ failed.push(...TRACKER_STAMPED_AGENTS);
75
+ return finish();
76
+ }
77
+ // A link at the folder sends every write below it into another place, so the folder is proven first.
78
+ if (await refuseUnrealFolder(links, path.join(claudeDir, 'agents', 'devflow'))) {
79
+ failed.push(...TRACKER_STAMPED_AGENTS);
80
+ return finish();
67
81
  }
68
82
  const tools = trackerServerTools(servers);
69
83
  for (const agent of TRACKER_STAMPED_AGENTS) {
@@ -94,7 +108,7 @@ export async function restampTrackerAgents(opts) {
94
108
  continue;
95
109
  }
96
110
  try {
97
- await writeFileAtomicExclusive(target, stamped.value.content);
111
+ await writeFileReplacingLink(target, stamped.value.content, links);
98
112
  rewritten.push(agent);
99
113
  }
100
114
  catch (err) {
@@ -102,6 +116,6 @@ export async function restampTrackerAgents(opts) {
102
116
  failed.push(agent);
103
117
  }
104
118
  }
105
- return { rewritten, unchanged, absent, failed };
119
+ return finish();
106
120
  }
107
121
  //# sourceMappingURL=tracker-servers.js.map