devflow-kit 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/backup-session.js +131 -0
  9. package/dist/cli/commands/compliance-prompts.js +9 -4
  10. package/dist/cli/commands/compliance.js +184 -24
  11. package/dist/cli/commands/curation.js +23 -10
  12. package/dist/cli/commands/gate-level-prompts.js +39 -25
  13. package/dist/cli/commands/gates.js +5 -7
  14. package/dist/cli/commands/init.js +169 -184
  15. package/dist/cli/commands/install-report.js +129 -0
  16. package/dist/cli/commands/legacy-sweep.js +159 -0
  17. package/dist/cli/commands/proxy.js +6 -1
  18. package/dist/cli/commands/rules.js +75 -21
  19. package/dist/cli/commands/skills.js +42 -9
  20. package/dist/cli/commands/tracker.js +9 -3
  21. package/dist/cli/commands/uninstall.js +339 -75
  22. package/dist/commands/bug-analysis.md +69 -120
  23. package/dist/commands/dynamic-build.md +23 -23
  24. package/dist/commands/implement.md +46 -32
  25. package/dist/commands/release.md +1 -1
  26. package/dist/commands/resolve.md +35 -31
  27. package/dist/commands/self-review.md +8 -7
  28. package/dist/compliance-off/agents/git.md +120 -0
  29. package/dist/compliance-off/agents/triage.md +180 -0
  30. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  31. package/dist/compliance-off/commands/code-review.md +520 -0
  32. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  33. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  34. package/dist/compliance-off/commands/implement.md +736 -0
  35. package/dist/compliance-off/commands/plan.md +671 -0
  36. package/dist/compliance-off/commands/release.md +309 -0
  37. package/dist/compliance-off/commands/resolve.md +742 -0
  38. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  39. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  41. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  42. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  43. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  45. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  52. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  59. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  66. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  67. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  68. package/dist/core/agent-models.js +19 -4
  69. package/dist/core/assets.js +126 -17
  70. package/dist/core/compliance-off-files.js +27 -0
  71. package/dist/core/compliance.js +14 -0
  72. package/dist/core/curation-variants.js +460 -112
  73. package/dist/core/flags.js +2 -2
  74. package/dist/core/fs-atomic.js +5 -1
  75. package/dist/core/installed-variants.js +63 -0
  76. package/dist/core/manifest.js +48 -5
  77. package/dist/core/mds-variants.js +700 -3
  78. package/dist/core/orphan-sweep.js +29 -2
  79. package/dist/core/path-set.js +15 -0
  80. package/dist/core/plugins.js +68 -10
  81. package/dist/core/reference-sweep.js +42 -25
  82. package/dist/core/retired-shipped-names.js +278 -0
  83. package/dist/curation-compliance-off/agents/triage.md +176 -0
  84. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  85. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  86. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  87. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  88. package/dist/curation-compliance-off/commands/implement.md +689 -0
  89. package/dist/curation-compliance-off/commands/plan.md +631 -0
  90. package/dist/curation-compliance-off/commands/release.md +279 -0
  91. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  92. package/dist/curation-off/agents/code.md +6 -6
  93. package/dist/curation-off/agents/fix.md +6 -6
  94. package/dist/curation-off/agents/harden.md +7 -6
  95. package/dist/curation-off/agents/refactor.md +6 -6
  96. package/dist/curation-off/commands/bug-analysis.md +69 -119
  97. package/dist/curation-off/commands/dynamic-build.md +23 -23
  98. package/dist/curation-off/commands/implement.md +46 -32
  99. package/dist/curation-off/commands/release.md +1 -1
  100. package/dist/curation-off/commands/resolve.md +35 -31
  101. package/dist/curation-off/commands/self-review.md +8 -7
  102. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  103. package/dist/targets/claude-code/behavior-install.js +23 -9
  104. package/dist/targets/claude-code/charter-render.js +374 -0
  105. package/dist/targets/claude-code/compliance-install.js +62 -17
  106. package/dist/targets/claude-code/compliance-off-files.js +249 -0
  107. package/dist/targets/claude-code/curation-install.js +632 -117
  108. package/dist/targets/claude-code/folder-swap.js +142 -0
  109. package/dist/targets/claude-code/install-backup.js +780 -0
  110. package/dist/targets/claude-code/installer.js +795 -199
  111. package/dist/targets/claude-code/language-stamp.js +6 -0
  112. package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
  113. package/dist/targets/claude-code/linked-folder.js +176 -0
  114. package/dist/targets/claude-code/reference-converge.js +131 -0
  115. package/dist/targets/claude-code/rules-folder.js +34 -0
  116. package/dist/targets/claude-code/shipped-set.js +160 -0
  117. package/dist/targets/claude-code/tracker-install.js +21 -8
  118. package/dist/targets/claude-code/tracker-servers.js +20 -6
  119. package/dist/targets/claude-code/workflow-folder-install.js +251 -0
  120. package/dist/variant-axes.json +109 -0
  121. package/package.json +1 -1
  122. package/src/assets/agents/_partials/_code.mds +6 -6
  123. package/src/assets/agents/evaluate.md +1 -1
  124. package/src/assets/agents/git.mds +32 -0
  125. package/src/assets/agents/harden.mds +7 -6
  126. package/src/assets/agents/test.md +21 -12
  127. package/src/assets/agents/triage.mds +2 -0
  128. package/src/assets/agents/validate.md +10 -10
  129. package/src/assets/commands/_partials/_compliance.mds +5 -3
  130. package/src/assets/commands/_partials/_engine.mds +9 -8
  131. package/src/assets/commands/_partials/_publication.mds +10 -1
  132. package/src/assets/commands/bug-analysis.mds +80 -126
  133. package/src/assets/commands/code-review.mds +43 -0
  134. package/src/assets/commands/dynamic-build.mds +109 -15
  135. package/src/assets/commands/dynamic-tickets.mds +13 -0
  136. package/src/assets/commands/implement.mds +145 -33
  137. package/src/assets/commands/plan.mds +19 -0
  138. package/src/assets/commands/release.mds +50 -1
  139. package/src/assets/commands/resolve.mds +217 -32
  140. package/src/assets/commands/self-review.mds +8 -7
  141. package/src/assets/mds/git/_ops.mds +23 -0
  142. package/src/assets/mds/git/_ops_steps.mds +17 -0
  143. package/src/assets/mds/git/_pr.mds +1 -1
  144. package/src/assets/mds/git/_references.mds +6 -0
  145. package/src/assets/mds/tracker/_common.mds +4 -0
  146. package/src/assets/mds/tracker/_github.mds +42 -0
  147. package/src/assets/mds/tracker/_jira.mds +43 -0
  148. package/src/assets/mds/tracker/_linear.mds +43 -0
  149. package/src/assets/rules/quality.md +2 -0
  150. package/src/assets/scripts/ci-wait.cjs +204 -126
  151. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  152. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  153. package/src/assets/scripts/hooks/preamble +18 -8
  154. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  155. package/src/assets/scripts/pr-evidence.cjs +41 -2
  156. package/src/assets/scripts/verify-evidence.cjs +408 -56
  157. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  158. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  159. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  160. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  162. package/src/assets/skills/git/SKILL.md +3 -3
  163. package/src/assets/skills/git/references/github-api.md +2 -139
  164. package/src/assets/skills/git/references/github-posting.md +158 -0
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  167. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  168. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Install-time render of the ambient orchestrator charter, and the implement sentinel.
3
+ *
4
+ * D-CHARTER-INSTALL-RENDER: the packaged charter (src/assets/scripts/hooks/assets/orchestrator-charter.md)
5
+ * is the FULL text. It routes to workflows and agents that a machine may not install, so `devflow init`
6
+ * writes a copy with the lines of every uninstalled owner deleted, and the session-start hook injects that
7
+ * copy whole. Decided here:
8
+ *
9
+ * - The render only deletes. It inserts no text and reads no name into the output: the inventory (which
10
+ * commands and agents the selection installs) is used for presence checks alone. The full source is
11
+ * also the fallback, so a render that cannot run installs the full charter and warns; it never aborts
12
+ * the install.
13
+ * - The span table is the whole contract between the charter text and this module. Every span is counted
14
+ * in the ORIGINAL source before any removal, and a span that occurs 0 or more than 1 times fails the
15
+ * render, so wording drift in the charter surfaces at install time and in the test, never as a
16
+ * silently wrong charter. The edits are ranges of the original source applied once, and two chosen
17
+ * ranges that share bytes fail the render rather than cut through each other.
18
+ * - The two debug-command spans overlap on `, else ` by design (`devflow:debug, else Explore ...`). They
19
+ * share one owner with opposite polarity, so a render removes exactly one of them: the fallback
20
+ * sentence when debug is installed, the command mention when it is not.
21
+ * - devflow-ambient's agents count as installed whatever the selection says. The charter belongs to that
22
+ * plugin, and the hooks inject it only while ambient mode is on, which is when `devflow init` installs
23
+ * the plugin; `devflow ambient --enable` warns when it is not installed.
24
+ * - The sentinel (`hooks/assets/implement-installed`) exists exactly when devflow-implement is installed.
25
+ * The per-prompt preamble branches on its existence with a shell builtin, so the plan-handoff directive
26
+ * runs only where the workflow it names exists. This module is the only writer of the charter and the
27
+ * sentinel at install time; the verbatim script copy skips both.
28
+ * - `mode: 'uninstall'` is the uninstall mode. An uninstall re-renders a charter that is installed from the
29
+ * manifest record and may take the sentinel away, but never creates a file. With no record to render
30
+ * from, `removeHandoffSentinel` takes the sentinel away alone and the charter stays as it is.
31
+ * - A change guard is asked about a file before the file is read, and every write or removal follows it.
32
+ * - After a package upgrade the installed charter stays as rendered until the next `devflow init`.
33
+ *
34
+ * Never throws: every failure is a warning and a `failed` outcome, because the install must not die on a
35
+ * charter. Silent on success.
36
+ */
37
+ import { promises as fs } from 'fs';
38
+ import * as path from 'path';
39
+ import { scriptsDir as packagedScriptsDir } from '../../core/assets.js';
40
+ import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
41
+ import { DEVFLOW_PLUGINS, registeredPluginNames } from '../../core/plugins.js';
42
+ /** The charter, relative to the scripts directory (packaged and installed alike). */
43
+ export const CHARTER_RELPATH = 'hooks/assets/orchestrator-charter.md';
44
+ /** The sentinel, relative to the scripts directory. It exists exactly when devflow-implement is installed. */
45
+ export const HANDOFF_SENTINEL_RELPATH = 'hooks/assets/implement-installed';
46
+ /** The plugin whose agents the charter always treats as installed. */
47
+ const AMBIENT_PLUGIN = 'devflow-ambient';
48
+ /** The permission bits of an installed charter or sentinel: the hooks directory is installed executable. */
49
+ const INSTALLED_MODE = 0o755;
50
+ /** The command whose presence the sentinel records. */
51
+ const HANDOFF_COMMAND = 'implement';
52
+ /** The workflow list's items, in the order the charter joins them with `, `. */
53
+ export const CHARTER_WORKFLOW_ITEMS = [
54
+ { text: 'devflow:implement', owner: { kind: 'command', name: 'implement' } },
55
+ { text: 'devflow:plan', owner: { kind: 'command', name: 'plan' } },
56
+ { text: 'devflow:research', owner: { kind: 'command', name: 'research' } },
57
+ { text: 'devflow:explore', owner: { kind: 'command', name: 'explore' } },
58
+ { text: 'devflow:code-review', owner: { kind: 'command', name: 'code-review' } },
59
+ { text: 'devflow:resolve', owner: { kind: 'command', name: 'resolve' } },
60
+ {
61
+ text: 'devflow:bug-analysis (hunt bugs in a branch before merge)',
62
+ owner: { kind: 'command', name: 'bug-analysis' },
63
+ },
64
+ ];
65
+ /** The joined list as the charter spells it: it occurs once, on the workflow bullet. */
66
+ export const CHARTER_WORKFLOW_LIST = CHARTER_WORKFLOW_ITEMS.map(item => item.text).join(', ');
67
+ /** Every other span: removal-only, each tied to one owner. */
68
+ export const CHARTER_SPANS = [
69
+ {
70
+ shape: 'text',
71
+ text: 'Fix (`OPERATION:` `validation-fix`, `alignment-fix`, `qa-fix` or `ci-fix`), else ',
72
+ owner: { kind: 'agent', name: 'fix' },
73
+ removedWhen: 'absent',
74
+ },
75
+ {
76
+ shape: 'text',
77
+ text: 'devflow:debug, else ',
78
+ owner: { kind: 'command', name: 'debug' },
79
+ removedWhen: 'absent',
80
+ },
81
+ {
82
+ shape: 'text',
83
+ text: ', else Explore investigates and Code fixes',
84
+ owner: { kind: 'command', name: 'debug' },
85
+ removedWhen: 'present',
86
+ },
87
+ {
88
+ shape: 'text',
89
+ text: '; never the Debug agent',
90
+ owner: { kind: 'agent', name: 'debug' },
91
+ removedWhen: 'absent',
92
+ },
93
+ {
94
+ shape: 'line',
95
+ text: '- Plan handoff: ',
96
+ owner: { kind: 'command', name: HANDOFF_COMMAND },
97
+ removedWhen: 'absent',
98
+ },
99
+ ];
100
+ /**
101
+ * The commands and agents a plugin selection installs.
102
+ *
103
+ * Only names in DEVFLOW_PLUGINS count: a manifest is file content, so an unknown, deleted or hostile name
104
+ * selects nothing, and a name that reaches here from a legacy install maps to its current plugin
105
+ * ({@link registeredPluginNames}). Every caller reaches this check, so it holds whatever the caller filtered.
106
+ * devflow-ambient's agents are always included. The names are used for presence checks only and never reach
107
+ * the rendered text.
108
+ */
109
+ export function charterInventory(pluginNames) {
110
+ const selected = new Set(registeredPluginNames(pluginNames));
111
+ const commands = new Set();
112
+ const agents = new Set();
113
+ for (const plugin of DEVFLOW_PLUGINS) {
114
+ if (plugin.name === AMBIENT_PLUGIN) {
115
+ for (const agent of plugin.agents)
116
+ agents.add(agent);
117
+ }
118
+ if (!selected.has(plugin.name))
119
+ continue;
120
+ for (const command of plugin.commands)
121
+ commands.add(command.startsWith('/') ? command.slice(1) : command);
122
+ for (const agent of plugin.agents)
123
+ agents.add(agent);
124
+ }
125
+ return { commands, agents };
126
+ }
127
+ /** How many times `needle` occurs in `haystack`, overlapping occurrences included. Bounded by the haystack length. */
128
+ function occurrencesOf(haystack, needle) {
129
+ if (needle.length === 0)
130
+ return 0;
131
+ let count = 0;
132
+ for (let from = haystack.indexOf(needle); from !== -1; from = haystack.indexOf(needle, from + 1))
133
+ count++;
134
+ return count;
135
+ }
136
+ /** The range of the whole line that holds `index`, its newline included. */
137
+ function lineRangeAt(source, index) {
138
+ const start = index === 0 ? 0 : source.lastIndexOf('\n', index - 1) + 1;
139
+ const newline = source.indexOf('\n', index);
140
+ return { start, end: newline === -1 ? source.length : newline + 1 };
141
+ }
142
+ /** The one place `span` sits in `source`, or the way it fails to. */
143
+ function locateSpan(source, span) {
144
+ if (span.shape === 'text') {
145
+ const count = occurrencesOf(source, span.text);
146
+ if (count !== 1)
147
+ return { reason: count === 0 ? 'missing' : 'repeated' };
148
+ const start = source.indexOf(span.text);
149
+ return { start, end: start + span.text.length };
150
+ }
151
+ const starts = [];
152
+ let lineStart = 0;
153
+ for (const line of source.split('\n')) {
154
+ if (line.startsWith(span.text))
155
+ starts.push(lineStart);
156
+ lineStart += line.length + 1;
157
+ }
158
+ if (starts.length !== 1)
159
+ return { reason: starts.length === 0 ? 'missing' : 'repeated' };
160
+ return lineRangeAt(source, starts[0]);
161
+ }
162
+ /**
163
+ * `source` with the spans of every uninstalled owner deleted.
164
+ *
165
+ * Pure. Counts every span in the original source first and fails on a span that occurs 0 or more than 1
166
+ * times; a render that cannot be trusted returns `{ok: false}` and the caller keeps the full source. The
167
+ * workflow list keeps its installed items in order (with none kept, the whole bullet line goes), every
168
+ * other span goes when its owner is absent (or, for the debug fallback, present).
169
+ */
170
+ export function renderCharter(source, installedCommands, installedAgents) {
171
+ const installed = (owner) => (owner.kind === 'command' ? installedCommands : installedAgents).has(owner.name);
172
+ // 1. Place every span in the original source, before anything is removed.
173
+ const listCount = occurrencesOf(source, CHARTER_WORKFLOW_LIST);
174
+ if (listCount !== 1) {
175
+ return { ok: false, reason: listCount === 0 ? 'missing' : 'repeated', span: CHARTER_WORKFLOW_LIST };
176
+ }
177
+ const listStart = source.indexOf(CHARTER_WORKFLOW_LIST);
178
+ const placed = [];
179
+ for (const span of CHARTER_SPANS) {
180
+ const at = locateSpan(source, span);
181
+ if ('reason' in at)
182
+ return { ok: false, reason: at.reason, span: span.text };
183
+ placed.push({ span, at });
184
+ }
185
+ // 2. Decide the edits, all against the original source.
186
+ const edits = [];
187
+ const kept = CHARTER_WORKFLOW_ITEMS.filter(item => installed(item.owner));
188
+ if (kept.length === 0) {
189
+ edits.push({ ...lineRangeAt(source, listStart), replacement: '', span: CHARTER_WORKFLOW_LIST });
190
+ }
191
+ else if (kept.length < CHARTER_WORKFLOW_ITEMS.length) {
192
+ edits.push({
193
+ start: listStart,
194
+ end: listStart + CHARTER_WORKFLOW_LIST.length,
195
+ replacement: kept.map(item => item.text).join(', '),
196
+ span: CHARTER_WORKFLOW_LIST,
197
+ });
198
+ }
199
+ for (const { span, at } of placed) {
200
+ const present = installed(span.owner);
201
+ if (span.removedWhen === 'absent' ? !present : present) {
202
+ edits.push({ start: at.start, end: at.end, replacement: '', span: span.text });
203
+ }
204
+ }
205
+ // 3. Apply them once, left to right. Two edits that share bytes cannot both be right.
206
+ edits.sort((a, b) => a.start - b.start);
207
+ let out = '';
208
+ let at = 0;
209
+ for (const edit of edits) {
210
+ if (edit.start < at)
211
+ return { ok: false, reason: 'overlap', span: edit.span };
212
+ out += source.slice(at, edit.start) + edit.replacement;
213
+ at = edit.end;
214
+ }
215
+ return { ok: true, content: out + source.slice(at) };
216
+ }
217
+ /** The file's bytes, or null when there is no such file. Any other failure throws to the caller's catch. */
218
+ async function readIfPresent(file) {
219
+ try {
220
+ return await fs.readFile(file);
221
+ }
222
+ catch (err) {
223
+ if (err.code === 'ENOENT')
224
+ return null;
225
+ throw err;
226
+ }
227
+ }
228
+ /** Whether the change guard lets this run touch `file`; with no guard, every file may change. */
229
+ async function permitted(file, mayChange) {
230
+ return mayChange === undefined || (await mayChange(file));
231
+ }
232
+ /** `warn`, made safe to call: a closed sink must not fail the install. */
233
+ function quietly(warn) {
234
+ return msg => {
235
+ try {
236
+ warn(msg);
237
+ }
238
+ catch { /* a closed sink must not fail the install */ }
239
+ };
240
+ }
241
+ /** Write `text` unless `current` already holds exactly those bytes, atomically and executable. The caller has asked the change guard. */
242
+ async function writeIfChanged(file, current, text) {
243
+ if (current !== null && current.equals(Buffer.from(text, 'utf-8')))
244
+ return 'unchanged';
245
+ await fs.mkdir(path.dirname(file), { recursive: true });
246
+ await writeFileAtomicExclusive(file, text, INSTALLED_MODE);
247
+ // A create mode passes through the umask, while composeScripts sets every file under hooks/ to 0755 on
248
+ // the next run whatever the umask is. Set the mode here too, or a second init changes it on disk.
249
+ if (process.platform !== 'win32')
250
+ await fs.chmod(file, INSTALLED_MODE);
251
+ return 'rewritten';
252
+ }
253
+ /**
254
+ * Remove the sentinel behind the change guard, without reading it; a file that is already gone is not an
255
+ * error. A failure is a warning and `failed`.
256
+ */
257
+ async function removeSentinel(sentinelTarget, mayChange, warn) {
258
+ try {
259
+ if (!(await permitted(sentinelTarget, mayChange)))
260
+ return 'failed';
261
+ await fs.unlink(sentinelTarget);
262
+ return 'rewritten';
263
+ }
264
+ catch (err) {
265
+ if (err.code === 'ENOENT')
266
+ return 'absent';
267
+ warn(`charter render: could not sync the implement sentinel (${sentinelTarget}) — ${String(err)}`);
268
+ return 'failed';
269
+ }
270
+ }
271
+ /** Why a render failed, in a sentence for the warning. */
272
+ function describeFailure(result) {
273
+ const problem = {
274
+ missing: 'is missing from',
275
+ repeated: 'occurs more than once in',
276
+ overlap: 'overlaps another span in',
277
+ }[result.reason];
278
+ return `the span "${result.span}" ${problem} the packaged charter, so the full charter was installed.`;
279
+ }
280
+ /**
281
+ * Write the charter rendered for `plugins`, and sync the implement sentinel with it.
282
+ *
283
+ * Called by installScripts after the script copy (which installViaFileCopy and `init --hud-only` both
284
+ * run), and by the selective uninstall phase. The two files are independent: each has its own try/catch,
285
+ * so a failure on one never costs the other. Each file is checked with the change guard before it is read.
286
+ * Bytes are compared with the bytes on disk at the moment of writing, so a rerun that changes nothing
287
+ * writes nothing.
288
+ */
289
+ export async function installRenderedCharter(opts) {
290
+ const { scriptsDir: target, plugins, mayChange, mode } = opts;
291
+ const warn = quietly(opts.warn);
292
+ // Precondition, asserted in production code: a relative scriptsDir would resolve against the working directory.
293
+ if (!path.isAbsolute(target)) {
294
+ warn(`charter render: scriptsDir is not an absolute path ("${target}") — the installed charter was not rendered`);
295
+ return { charter: 'failed', sentinel: 'failed' };
296
+ }
297
+ const inventory = charterInventory(plugins.map(plugin => (typeof plugin === 'string' ? plugin : plugin.name)));
298
+ const charterTarget = path.join(target, CHARTER_RELPATH);
299
+ const sentinelTarget = path.join(target, HANDOFF_SENTINEL_RELPATH);
300
+ const packagedRoot = () => {
301
+ try {
302
+ return opts.packagedScriptsDir ?? packagedScriptsDir();
303
+ }
304
+ catch (err) {
305
+ warn(`charter render: cannot find the packaged scripts — ${String(err)}`);
306
+ return null;
307
+ }
308
+ };
309
+ const installCharter = async () => {
310
+ try {
311
+ if (!(await permitted(charterTarget, mayChange)))
312
+ return 'failed';
313
+ const current = await readIfPresent(charterTarget);
314
+ if (current === null && mode === 'uninstall')
315
+ return 'absent';
316
+ const root = packagedRoot();
317
+ if (root === null)
318
+ return 'failed';
319
+ const source = await fs.readFile(path.join(root, CHARTER_RELPATH), 'utf-8');
320
+ const rendered = renderCharter(source, inventory.commands, inventory.agents);
321
+ if (!rendered.ok)
322
+ warn(`charter render: ${describeFailure(rendered)}`);
323
+ return await writeIfChanged(charterTarget, current, rendered.ok ? rendered.content : source);
324
+ }
325
+ catch (err) {
326
+ warn(`charter render: could not install the charter (${charterTarget}) — ${String(err)}`);
327
+ return 'failed';
328
+ }
329
+ };
330
+ const syncSentinel = async () => {
331
+ if (!inventory.commands.has(HANDOFF_COMMAND))
332
+ return removeSentinel(sentinelTarget, mayChange, warn);
333
+ try {
334
+ if (!(await permitted(sentinelTarget, mayChange)))
335
+ return 'failed';
336
+ const current = await readIfPresent(sentinelTarget);
337
+ if (mode === 'uninstall')
338
+ return current === null ? 'absent' : 'unchanged';
339
+ const root = packagedRoot();
340
+ if (root === null)
341
+ return 'failed';
342
+ const text = await fs.readFile(path.join(root, HANDOFF_SENTINEL_RELPATH), 'utf-8');
343
+ return await writeIfChanged(sentinelTarget, current, text);
344
+ }
345
+ catch (err) {
346
+ warn(`charter render: could not sync the implement sentinel (${sentinelTarget}) — ${String(err)}`);
347
+ return 'failed';
348
+ }
349
+ };
350
+ const charter = await installCharter();
351
+ const sentinel = await syncSentinel();
352
+ return { charter, sentinel };
353
+ }
354
+ /**
355
+ * Take the implement sentinel away when the removed plugins include the owner of the handoff command, and
356
+ * touch nothing else. For an uninstall with no manifest record: the charter has nothing to be rendered
357
+ * from and stays as it is, but the sentinel needs no record, and without it the per-prompt preamble falls
358
+ * back to the generic reminder instead of naming a workflow that is gone.
359
+ *
360
+ * `rewritten` when the sentinel was removed, `absent` when there was none, `unchanged` when the removed
361
+ * plugins leave implement installed, `failed` when the guard refused or a warning says what went wrong.
362
+ * Never throws.
363
+ */
364
+ export async function removeHandoffSentinel(opts) {
365
+ const warn = quietly(opts.warn);
366
+ if (!path.isAbsolute(opts.scriptsDir)) {
367
+ warn(`charter render: scriptsDir is not an absolute path ("${opts.scriptsDir}") — the implement sentinel was not removed`);
368
+ return 'failed';
369
+ }
370
+ if (!charterInventory(opts.removedPlugins).commands.has(HANDOFF_COMMAND))
371
+ return 'unchanged';
372
+ return removeSentinel(path.join(opts.scriptsDir, HANDOFF_SENTINEL_RELPATH), opts.mayChange, warn);
373
+ }
374
+ //# sourceMappingURL=charter-render.js.map
@@ -24,7 +24,9 @@ import * as path from 'path';
24
24
  import { skillsDir, rulesDir } from '../../core/assets.js';
25
25
  import { ALWAYS_PRESENT_REFS, COMPLIANCE_FRAMEWORKS, normalizeFrameworks } from '../../core/compliance.js';
26
26
  import { parseComplianceFragment, composeComplianceSkill, composeComplianceRule } from '../../core/compliance-compose.js';
27
- import { validateSkillShadow, validateRuleShadow } from './installer.js';
27
+ import { swapFolderIn } from './folder-swap.js';
28
+ import { copyDirectory, validateSkillShadow, validateRuleShadow } from './installer.js';
29
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
28
30
  // ── Path helpers ───────────────────────────────────────────────────────────
29
31
  /** Every registry framework id — the reference set every install carries. */
30
32
  const ALL_FRAMEWORK_IDS = COMPLIANCE_FRAMEWORKS.map(fw => fw.id);
@@ -32,6 +34,14 @@ const ALL_FRAMEWORK_IDS = COMPLIANCE_FRAMEWORKS.map(fw => fw.id);
32
34
  function skillTarget(claudeDir) {
33
35
  return path.join(claudeDir, 'skills', 'devflow:compliance');
34
36
  }
37
+ /** The folder the compliance skill is built in before it is renamed into place: {claudeDir}/skills/devflow:compliance.tmp */
38
+ export function complianceSkillTempPath(claudeDir) {
39
+ return `${skillTarget(claudeDir)}.tmp`;
40
+ }
41
+ /** Where the compliance skill folder waits while the new one is renamed in: {claudeDir}/skills/devflow:compliance.old */
42
+ export function complianceSkillAsidePath(claudeDir) {
43
+ return `${skillTarget(claudeDir)}.old`;
44
+ }
35
45
  /** Installed compliance rule file: {claudeDir}/rules/devflow/compliance.md */
36
46
  function ruleTarget(claudeDir) {
37
47
  return path.join(claudeDir, 'rules', 'devflow', 'compliance.md');
@@ -92,13 +102,22 @@ async function loadComplianceFragments(canonicalSrc, frameworks, warn) {
92
102
  * `fragments` is loaded once by convergeComplianceArtifacts and shared with the rule
93
103
  * installer — the SKILL.md and the rule compose from the same parsed set.
94
104
  *
95
- * Build under a .tmp sibling, remove old target, rename.
105
+ * Build under a .tmp sibling, then swap it in: the old target waits in a .old sibling while the new one
106
+ * is renamed in. A file in the old target that devflow did not ship moves to the backup folder first;
107
+ * if one cannot move, nothing is swapped and the shipped files are written into the old target (see
108
+ * {@link swapFolderIn}). An old target that is a link is moved aside as the link itself, never
109
+ * followed, so what it points to is left as it was; the link is recorded in `links`.
96
110
  * Unexpected I/O failures are reported via warn; never thrown.
97
111
  */
98
- async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, warn) {
112
+ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, warn, links, backups) {
99
113
  const canonicalSrc = path.join(skillsDir(), 'compliance');
100
114
  const target = skillTarget(claudeDir);
101
- const tmpTarget = `${target}.tmp`;
115
+ const tmpTarget = complianceSkillTempPath(claudeDir);
116
+ // A skills folder that is a link or not a folder is refused before anything is built: the swap
117
+ // would move and remove below it, in the folder the link points to. The refusal is recorded in
118
+ // `links`, and the converge is not clean.
119
+ if (await refuseUnrealFolder(links, path.dirname(target)))
120
+ return;
102
121
  // Determine SKILL.md source: prefer a valid shadow, else use canonical.
103
122
  const skillShadowDir = path.join(devflowDir, 'skills', 'compliance');
104
123
  const shadowState = await validateSkillShadow(skillShadowDir);
@@ -148,9 +167,18 @@ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments
148
167
  warn(`compliance: reference "${fw}.md" not installed — ${String(err)}`);
149
168
  }
150
169
  }
151
- // Swap: remove old target, rename tmp into place (two calls, not atomic).
152
- await fs.rm(target, { recursive: true, force: true });
153
- await fs.rename(tmpTarget, target);
170
+ // Swap: the old target waits aside, the tmp folder is renamed into place. The link is recorded
171
+ // once the real folder stands in its place, so the report matches its wording.
172
+ await swapFolderIn({
173
+ backups,
174
+ target,
175
+ staged: tmpTarget,
176
+ aside: complianceSkillAsidePath(claudeDir),
177
+ links,
178
+ mergeInto: () => copyDirectory(tmpTarget, target, undefined, links),
179
+ });
180
+ // The tmp folder is gone after a swap; a merge leaves it behind.
181
+ await fs.rm(tmpTarget, { recursive: true, force: true });
154
182
  }
155
183
  catch (err) {
156
184
  // Clean up tmp on failure (best-effort) so no orphan is left behind.
@@ -174,9 +202,11 @@ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments
174
202
  * source (never the shadow — fragments are registry-owned) and shared with the skill
175
203
  * installer.
176
204
  *
177
- * I/O failures are reported via warn; never thrown.
205
+ * I/O failures are reported via warn; never thrown. The caller has proved that `rules/devflow` is a
206
+ * real folder or absent. A link at the rule file is replaced by the rename of the atomic write,
207
+ * never followed, and recorded in `links`.
178
208
  */
179
- async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, warn) {
209
+ async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, warn, links) {
180
210
  const ruleShadowFile = path.join(devflowDir, 'rules', 'compliance.md');
181
211
  const canonicalRuleSrc = path.join(rulesDir(), 'compliance.md');
182
212
  const target = ruleTarget(claudeDir);
@@ -188,7 +218,7 @@ async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, war
188
218
  for (const w of warnings)
189
219
  warn(`compliance: ${w}`);
190
220
  await fs.mkdir(path.dirname(target), { recursive: true });
191
- await fs.writeFile(target, composed, 'utf-8');
221
+ await writeFileReplacingLink(target, composed, links);
192
222
  }
193
223
  catch (err) {
194
224
  warn(`compliance: rule install failed — ${String(err)}`);
@@ -213,13 +243,13 @@ async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, war
213
243
  * principle in engineering.md.
214
244
  */
215
245
  export async function convergeComplianceArtifacts(opts) {
216
- const { claudeDir, devflowDir, enabled, frameworks, rulesEnabled, warn } = opts;
246
+ const { claudeDir, devflowDir, enabled, frameworks, rulesEnabled, warn, backups } = opts;
217
247
  // S78: claudeDir precondition — reliability rule: assert invariants in production code.
218
248
  // An empty or relative claudeDir would make skillTarget/ruleTarget resolve to unexpected
219
249
  // locations. Warn and bail rather than running fs.rm with a potentially wrong path.
220
250
  if (!path.isAbsolute(claudeDir)) {
221
251
  warn(`compliance: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
222
- return { removedPreexisting: false, converged: false };
252
+ return { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] };
223
253
  }
224
254
  // Trust boundary. `frameworks` reaches here straight from the manifest on every
225
255
  // caller's bare --enable path, and normalizeComplianceFeature only type-checks it.
@@ -244,10 +274,24 @@ export async function convergeComplianceArtifacts(opts) {
244
274
  // The skill and the rule are independent operations. An error in
245
275
  // installSkillDir is caught internally and reported via trackingWarn, so
246
276
  // execution always continues to the rule step.
247
- await installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, trackingWarn);
277
+ const links = newLinkReport();
278
+ await installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, trackingWarn, links, backups);
279
+ // The rule folder is proven before the rule is written or removed: a link there sends both into
280
+ // another place. The skill folder is swapped whole above, and a swap never follows a link.
281
+ const finish = (removedPreexisting) => {
282
+ warnLinkReport(links, warn);
283
+ return {
284
+ removedPreexisting,
285
+ converged: converged && links.refusedFolders.length === 0,
286
+ replacedLinks: links.replacedLinks,
287
+ refusedFolders: links.refusedFolders,
288
+ };
289
+ };
290
+ if (await refuseUnrealFolder(links, path.dirname(ruleTarget(claudeDir))))
291
+ return finish(false);
248
292
  if (enabled && rulesEnabled) {
249
- await installRuleFile(claudeDir, devflowDir, safeFrameworks, fragments, trackingWarn);
250
- return { removedPreexisting: false, converged };
293
+ await installRuleFile(claudeDir, devflowDir, safeFrameworks, fragments, trackingWarn, links);
294
+ return finish(false);
251
295
  }
252
296
  // Compliance off, or rules off: no rule. Probe first so a disable convergence
253
297
  // can report that it removed one; absence is already the desired end state.
@@ -260,7 +304,7 @@ export async function convergeComplianceArtifacts(opts) {
260
304
  trackingWarn(`compliance: failed to remove rule — ${String(err)}`);
261
305
  }
262
306
  }
263
- return { removedPreexisting: !enabled && ruleExisted, converged };
307
+ return finish(!enabled && ruleExisted);
264
308
  }
265
309
  // ── Manifest-slice wrapper ─────────────────────────────────────────────────
266
310
  /**
@@ -278,7 +322,7 @@ export async function convergeComplianceArtifacts(opts) {
278
322
  * than the value stored in the manifest.
279
323
  */
280
324
  export async function convergeFromManifest(opts) {
281
- const { claudeDir, devflowDir, manifest, warn, rulesEnabledOverride } = opts;
325
+ const { claudeDir, devflowDir, manifest, warn, rulesEnabledOverride, backups } = opts;
282
326
  return convergeComplianceArtifacts({
283
327
  claudeDir,
284
328
  devflowDir,
@@ -286,6 +330,7 @@ export async function convergeFromManifest(opts) {
286
330
  frameworks: manifest.features.compliance.frameworks,
287
331
  rulesEnabled: rulesEnabledOverride ?? manifest.features.rules,
288
332
  warn,
333
+ backups,
289
334
  });
290
335
  }
291
336
  //# sourceMappingURL=compliance-install.js.map