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
@@ -1,6 +1,11 @@
1
1
  ## Operation: manage-debt
2
2
 
3
- Update tech debt backlog with deferred issues from resolution and pre-existing issues from code review.
3
+ Update tech debt backlog from `{REVIEW_DIR}/resolution-summary.md`:
4
+ - Add only entries of `## Fix Separately` (FIX_SEPARATE) and `## Deferred to Tech Debt` (TECH_DEBT). A section ends at the next heading.
5
+ - An empty section adds none.
6
+ - If `resolution-summary.md` is missing, return `TRACEABILITY: DEGRADED (resolution summary missing)` and do not update the backlog.
7
+ - Never add an item that also has an entry in `## Escalations`, `## Blocked` or `## Fixed Issues`.
8
+ - Read no review report: in `{REVIEW_DIR}`, read only `resolution-summary.md`.
4
9
 
5
10
  **Input:** `REVIEW_DIR`, `TIMESTAMP`, `WORKTREE_PATH` (optional)
6
11
 
@@ -31,7 +36,6 @@ Load when the resolved tracker provider is `github` and the operation is `manage
31
36
  3. Extract items to add:
32
37
  - `## Fix Separately` entries from `{REVIEW_DIR}/resolution-summary.md` (FIX_SEPARATE from Triage agent)
33
38
  - `## Deferred to Tech Debt` entries from `{REVIEW_DIR}/resolution-summary.md` (TECH_DEBT from Triage agent)
34
- - Pre-existing issues (Category 3) from review reports
35
39
  4. Deduplicate against existing items using semantic matching
36
40
  5. Remove items that have been fixed (verify in codebase)
37
41
  6. Compose updated issue body to `$DEVFLOW_BODY_RAW`; apply the Comment-sink scrub (D11) and post via `gh issue edit {number} --body-file "$DEVFLOW_BODY"`
@@ -1,6 +1,11 @@
1
1
  ## Operation: manage-debt
2
2
 
3
- Update tech debt backlog with deferred issues from resolution and pre-existing issues from code review.
3
+ Update tech debt backlog from `{REVIEW_DIR}/resolution-summary.md`:
4
+ - Add only entries of `## Fix Separately` (FIX_SEPARATE) and `## Deferred to Tech Debt` (TECH_DEBT). A section ends at the next heading.
5
+ - An empty section adds none.
6
+ - If `resolution-summary.md` is missing, return `TRACEABILITY: DEGRADED (resolution summary missing)` and do not update the backlog.
7
+ - Never add an item that also has an entry in `## Escalations`, `## Blocked` or `## Fixed Issues`.
8
+ - Read no review report: in `{REVIEW_DIR}`, read only `resolution-summary.md`.
4
9
 
5
10
  **Input:** `REVIEW_DIR`, `TIMESTAMP`, `WORKTREE_PATH` (optional)
6
11
 
@@ -31,7 +36,6 @@ Load when the resolved tracker provider is `jira` and the operation is `manage-d
31
36
  3. Extract items to add:
32
37
  - `## Fix Separately` entries from `{REVIEW_DIR}/resolution-summary.md` (FIX_SEPARATE from Triage agent)
33
38
  - `## Deferred to Tech Debt` entries from `{REVIEW_DIR}/resolution-summary.md` (TECH_DEBT from Triage agent)
34
- - Pre-existing issues (Category 3) from review reports
35
39
  4. Deduplicate against existing items using semantic matching.
36
40
  5. Remove items that have been fixed (verify in codebase).
37
41
  6. Compose the updated description and post it through the gate in `### Posting gate` below, using the *update description* capability.
@@ -1,6 +1,11 @@
1
1
  ## Operation: manage-debt
2
2
 
3
- Update tech debt backlog with deferred issues from resolution and pre-existing issues from code review.
3
+ Update tech debt backlog from `{REVIEW_DIR}/resolution-summary.md`:
4
+ - Add only entries of `## Fix Separately` (FIX_SEPARATE) and `## Deferred to Tech Debt` (TECH_DEBT). A section ends at the next heading.
5
+ - An empty section adds none.
6
+ - If `resolution-summary.md` is missing, return `TRACEABILITY: DEGRADED (resolution summary missing)` and do not update the backlog.
7
+ - Never add an item that also has an entry in `## Escalations`, `## Blocked` or `## Fixed Issues`.
8
+ - Read no review report: in `{REVIEW_DIR}`, read only `resolution-summary.md`.
4
9
 
5
10
  **Input:** `REVIEW_DIR`, `TIMESTAMP`, `WORKTREE_PATH` (optional)
6
11
 
@@ -31,7 +36,6 @@ Load when the resolved tracker provider is `linear` and the operation is `manage
31
36
  3. Extract items to add:
32
37
  - `## Fix Separately` entries from `{REVIEW_DIR}/resolution-summary.md` (FIX_SEPARATE from Triage agent)
33
38
  - `## Deferred to Tech Debt` entries from `{REVIEW_DIR}/resolution-summary.md` (TECH_DEBT from Triage agent)
34
- - Pre-existing issues (Category 3) from review reports
35
39
  4. Deduplicate against existing items using semantic matching.
36
40
  5. Remove items that have been fixed (verify in codebase).
37
41
  6. Compose the updated description and post it through the gate in `### Posting gate` below, using the *update description* capability.
@@ -22,6 +22,7 @@
22
22
  import { promises as fs } from 'fs';
23
23
  import * as path from 'path';
24
24
  import { installRuleFile } from './installer.js';
25
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport } from './linked-folder.js';
25
26
  // ── Types ──────────────────────────────────────────────────────────────────
26
27
  export const BEHAVIOR_RULE_NAME = 'behavior';
27
28
  // ── Path helpers ───────────────────────────────────────────────────────────
@@ -47,46 +48,59 @@ async function pathExists(p) {
47
48
  * enabled + !rulesEnabled → rule file removed
48
49
  * !enabled → rule file removed
49
50
  *
51
+ * `rules/devflow` that is a link, or not a folder, is refused: the rule is neither written nor
52
+ * removed there, and the folder is named in `refusedFolders` and in a warning. A rule file that is a
53
+ * link is replaced by a real file, or unlinked on removal, and never written through.
54
+ *
50
55
  * Never throws. A relative `claudeDir` is refused with a warning rather than resolved
51
56
  * against the working directory, because the removal arm deletes a path.
52
57
  */
53
58
  export async function convergeBehaviorRule(opts) {
54
59
  const { claudeDir, devflowDir, enabled, rulesEnabled, warn } = opts;
60
+ const links = newLinkReport();
61
+ const finish = (state) => {
62
+ warnLinkReport(links, warn);
63
+ return { ...state, replacedLinks: links.replacedLinks, refusedFolders: links.refusedFolders };
64
+ };
55
65
  if (!path.isAbsolute(claudeDir)) {
56
66
  warn(`behavior: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
57
- return { installed: false, removed: false, converged: false };
67
+ return finish({ installed: false, removed: false, converged: false });
58
68
  }
59
69
  const target = behaviorRulePath(claudeDir);
70
+ // A link at the folder sends the write and the removal into another place, so the folder is proven first.
71
+ if (await refuseUnrealFolder(links, path.dirname(target))) {
72
+ return finish({ installed: await pathExists(target), removed: false, converged: false });
73
+ }
60
74
  if (enabled && rulesEnabled) {
61
75
  try {
62
76
  await fs.mkdir(path.dirname(target), { recursive: true });
63
- const outcome = await installRuleFile(BEHAVIOR_RULE_NAME, devflowDir, path.dirname(target));
77
+ const outcome = await installRuleFile(BEHAVIOR_RULE_NAME, devflowDir, path.dirname(target), links);
64
78
  if (outcome === 'skipped') {
65
79
  warn('behavior: rule install failed — the file could not be copied');
66
- return { installed: false, removed: false, converged: false };
80
+ return finish({ installed: false, removed: false, converged: false });
67
81
  }
68
82
  if (outcome === 'source-invalid-shadow:empty-shadow-file' || outcome === 'source-invalid-shadow:not-a-file') {
69
83
  warn("behavior: the shadow is invalid — Devflow's version was installed");
70
- return { installed: true, removed: false, converged: false };
84
+ return finish({ installed: true, removed: false, converged: false });
71
85
  }
72
- return { installed: true, removed: false, converged: true };
86
+ return finish({ installed: true, removed: false, converged: true });
73
87
  }
74
88
  catch (err) {
75
89
  warn(`behavior: rule install failed — ${String(err)}`);
76
- return { installed: false, removed: false, converged: false };
90
+ return finish({ installed: false, removed: false, converged: false });
77
91
  }
78
92
  }
79
93
  // Off, or rules off: no rule. Probe first so the caller can report a removal;
80
94
  // absence is already the desired end state.
81
95
  if (!(await pathExists(target)))
82
- return { installed: false, removed: false, converged: true };
96
+ return finish({ installed: false, removed: false, converged: true });
83
97
  try {
84
98
  await fs.rm(target, { force: true });
85
- return { installed: false, removed: true, converged: true };
99
+ return finish({ installed: false, removed: true, converged: true });
86
100
  }
87
101
  catch (err) {
88
102
  warn(`behavior: failed to remove rule — ${String(err)}`);
89
- return { installed: true, removed: false, converged: false };
103
+ return finish({ installed: true, removed: false, converged: false });
90
104
  }
91
105
  }
92
106
  // ── Manifest-slice wrapper ─────────────────────────────────────────────────
@@ -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
@@ -25,6 +25,7 @@ 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
27
  import { validateSkillShadow, validateRuleShadow } from './installer.js';
28
+ import { newLinkReport, refuseUnrealFolder, removeReportingLink, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
28
29
  // ── Path helpers ───────────────────────────────────────────────────────────
29
30
  /** Every registry framework id — the reference set every install carries. */
30
31
  const ALL_FRAMEWORK_IDS = COMPLIANCE_FRAMEWORKS.map(fw => fw.id);
@@ -92,10 +93,11 @@ async function loadComplianceFragments(canonicalSrc, frameworks, warn) {
92
93
  * `fragments` is loaded once by convergeComplianceArtifacts and shared with the rule
93
94
  * installer — the SKILL.md and the rule compose from the same parsed set.
94
95
  *
95
- * Build under a .tmp sibling, remove old target, rename.
96
+ * Build under a .tmp sibling, remove old target, rename. An old target that is a link is unlinked,
97
+ * never followed, so what it points to is left as it was; the link is recorded in `links`.
96
98
  * Unexpected I/O failures are reported via warn; never thrown.
97
99
  */
98
- async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, warn) {
100
+ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, warn, links) {
99
101
  const canonicalSrc = path.join(skillsDir(), 'compliance');
100
102
  const target = skillTarget(claudeDir);
101
103
  const tmpTarget = `${target}.tmp`;
@@ -148,9 +150,11 @@ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments
148
150
  warn(`compliance: reference "${fw}.md" not installed — ${String(err)}`);
149
151
  }
150
152
  }
151
- // Swap: remove old target, rename tmp into place (two calls, not atomic).
152
- await fs.rm(target, { recursive: true, force: true });
153
+ // Swap: remove old target, rename tmp into place (two calls, not atomic). The link is recorded
154
+ // once the real folder stands in its place, so the report matches its wording.
155
+ const unlinked = await removeReportingLink(target);
153
156
  await fs.rename(tmpTarget, target);
157
+ links.replacedLinks.push(...unlinked);
154
158
  }
155
159
  catch (err) {
156
160
  // Clean up tmp on failure (best-effort) so no orphan is left behind.
@@ -174,9 +178,11 @@ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments
174
178
  * source (never the shadow — fragments are registry-owned) and shared with the skill
175
179
  * installer.
176
180
  *
177
- * I/O failures are reported via warn; never thrown.
181
+ * I/O failures are reported via warn; never thrown. The caller has proved that `rules/devflow` is a
182
+ * real folder or absent. A link at the rule file is replaced by the rename of the atomic write,
183
+ * never followed, and recorded in `links`.
178
184
  */
179
- async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, warn) {
185
+ async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, warn, links) {
180
186
  const ruleShadowFile = path.join(devflowDir, 'rules', 'compliance.md');
181
187
  const canonicalRuleSrc = path.join(rulesDir(), 'compliance.md');
182
188
  const target = ruleTarget(claudeDir);
@@ -188,7 +194,7 @@ async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, war
188
194
  for (const w of warnings)
189
195
  warn(`compliance: ${w}`);
190
196
  await fs.mkdir(path.dirname(target), { recursive: true });
191
- await fs.writeFile(target, composed, 'utf-8');
197
+ await writeFileReplacingLink(target, composed, links);
192
198
  }
193
199
  catch (err) {
194
200
  warn(`compliance: rule install failed — ${String(err)}`);
@@ -219,7 +225,7 @@ export async function convergeComplianceArtifacts(opts) {
219
225
  // locations. Warn and bail rather than running fs.rm with a potentially wrong path.
220
226
  if (!path.isAbsolute(claudeDir)) {
221
227
  warn(`compliance: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
222
- return { removedPreexisting: false, converged: false };
228
+ return { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] };
223
229
  }
224
230
  // Trust boundary. `frameworks` reaches here straight from the manifest on every
225
231
  // caller's bare --enable path, and normalizeComplianceFeature only type-checks it.
@@ -244,10 +250,24 @@ export async function convergeComplianceArtifacts(opts) {
244
250
  // The skill and the rule are independent operations. An error in
245
251
  // installSkillDir is caught internally and reported via trackingWarn, so
246
252
  // execution always continues to the rule step.
247
- await installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, trackingWarn);
253
+ const links = newLinkReport();
254
+ await installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, trackingWarn, links);
255
+ // The rule folder is proven before the rule is written or removed: a link there sends both into
256
+ // another place. The skill folder is swapped whole above, and a swap never follows a link.
257
+ const finish = (removedPreexisting) => {
258
+ warnLinkReport(links, warn);
259
+ return {
260
+ removedPreexisting,
261
+ converged: converged && links.refusedFolders.length === 0,
262
+ replacedLinks: links.replacedLinks,
263
+ refusedFolders: links.refusedFolders,
264
+ };
265
+ };
266
+ if (await refuseUnrealFolder(links, path.dirname(ruleTarget(claudeDir))))
267
+ return finish(false);
248
268
  if (enabled && rulesEnabled) {
249
- await installRuleFile(claudeDir, devflowDir, safeFrameworks, fragments, trackingWarn);
250
- return { removedPreexisting: false, converged };
269
+ await installRuleFile(claudeDir, devflowDir, safeFrameworks, fragments, trackingWarn, links);
270
+ return finish(false);
251
271
  }
252
272
  // Compliance off, or rules off: no rule. Probe first so a disable convergence
253
273
  // can report that it removed one; absence is already the desired end state.
@@ -260,7 +280,7 @@ export async function convergeComplianceArtifacts(opts) {
260
280
  trackingWarn(`compliance: failed to remove rule — ${String(err)}`);
261
281
  }
262
282
  }
263
- return { removedPreexisting: !enabled && ruleExisted, converged };
283
+ return finish(!enabled && ruleExisted);
264
284
  }
265
285
  // ── Manifest-slice wrapper ─────────────────────────────────────────────────
266
286
  /**