devflow-kit 2.4.0 → 3.0.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 (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -1,19 +1,24 @@
1
- import { Command } from 'commander';
1
+ import { Command, Option } from 'commander';
2
2
  import { promises as fs } from 'fs';
3
3
  import * as path from 'path';
4
4
  import { execSync } from 'child_process';
5
5
  import * as p from '@clack/prompts';
6
6
  import color from 'picocolors';
7
- import { getInstallationPaths } from '../../targets/claude-code/claude-paths.js';
7
+ import { resolveInstallationPaths } from '../../targets/claude-code/claude-paths.js';
8
8
  import { getGitRoot } from '../../core/git.js';
9
+ import { isSameLocation, withoutHomeRoots } from '../../core/same-location.js';
10
+ import { pruneHookLogDirs, MAX_HOOK_LOG_DIRS } from '../../core/hook-log-dirs.js';
11
+ import { getLedgerRoot } from '../../core/ledger-root.js';
9
12
  import { installViaFileCopy, composeScripts } from '../../targets/claude-code/installer.js';
10
- import { installSettings, installManagedSettings, installClaudeignore, discoverProjectGitRoots, updateGitignore, ensureDevflowGitignore, createDocsStructure, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
11
- import { DEVFLOW_PLUGINS, LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES, buildAssetMaps, buildFullSkillsMap, buildRulesMap, partitionSelectablePlugins, WORKFLOW_ORDER, parsePluginSelection, resolveFeatureRedirect } from '../../core/plugins.js';
13
+ import { formatOverlaySummary, formatSkillScopeSummary, formatTrackerAssetSummary, isPluginListUnchanged } from './install-report.js';
14
+ import { convergeTrackerArtifacts } from '../../targets/claude-code/tracker-install.js';
15
+ import { installSettings, installManagedSettings, installClaudeignore, discoverProjectGitRoots, ensureDevflowGitignore, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
16
+ import { DEVFLOW_PLUGINS, LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES, buildAssetMaps, buildScopedSkillsMap, buildRulesMap, partitionSelectablePlugins, WORKFLOW_ORDER, parsePluginSelection, resolveFeatureRedirect } from '../../core/plugins.js';
12
17
  import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
13
18
  import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../../core/safe-delete.js';
14
19
  import { generateSafeDeleteBlock, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../../core/safe-delete-install.js';
15
- import { addAmbientHook, removeAmbientHook } from './ambient.js';
16
- import { addMemoryHooks, removeMemoryHooks } from './memory.js';
20
+ import { convergeAmbientHooks } from './ambient.js';
21
+ import { convergeMemoryHooks, drainMemoryQueue } from './memory.js';
17
22
  import { addCaptureHooks, removeCaptureHooks } from './capture.js';
18
23
  import { removeDreamHook } from './legacy-hooks.js';
19
24
  import { addProxyHooks, removeProxyHooks, applyProxyEnv, stripProxyEnv, runProxyPreflight, buildRealPreflightDeps } from './proxy.js';
@@ -26,16 +31,20 @@ import { loadConfig as loadHudConfig, saveConfig as saveHudConfig } from '../../
26
31
  import { readManifest, writeManifest, resolvePluginList, detectUpgrade } from '../../core/manifest.js';
27
32
  import { convergeFlagsIntoSettings, countActiveFlags, readViewMode } from '../../core/flags.js';
28
33
  import { addContextHook, removeContextHook, hasContextHook } from './context.js';
29
- import { writeConfig, readConfigIfPresent } from '../../core/feature-config.js';
30
- import { resolveInitSeed, applyCliToggles, resolveResetGatedInputs } from './init-seed.js';
34
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
35
+ import { writeManagedConfig, readConfigIfPresent, DEFAULT_CONFIG } from '../../core/feature-config.js';
36
+ import { drainLearningQueue } from '../../core/learning-queue-cleanup.js';
37
+ import { removeManagedDenyList, describeManagedDenyRemoval } from './security.js';
38
+ import { resolveInitSeed, applyCliToggles, resolveResetGatedInputs, resolvePluginsToInstall } from './init-seed.js';
31
39
  import { parseFrameworkList, normalizeFrameworks } from '../../core/compliance.js';
32
40
  import { formatComplianceSummary, shouldRunComplianceStep, runComplianceStep, buildClackCompliancePrompts, } from './compliance-prompts.js';
41
+ import { applyTrackerSentinel, parseTrackerId, rearmTrackerInference, DEFAULT_TRACKER_PROVIDER, } from '../../core/tracker.js';
42
+ import { formatTrackerSummary, shouldRunTrackerStep, runTrackerStep, buildClackTrackerPrompts, } from './tracker-prompts.js';
33
43
  import { shouldRunAttributionStep, runAttributionStep, buildClackAttributionPrompts, applyAttributionAnswer, attributionSeedFrom, } from './attribution-prompts.js';
34
44
  import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
35
- import { getPendingTurnsPath, getPendingTurnsProcessingPath } from '../../core/project-paths.js';
36
45
  import * as os from 'os';
37
46
  // Re-export pure functions for tests (canonical source is post-install.ts)
38
- export { substituteSettingsTemplate, computeGitignoreAppend, mergeDenyList, discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
47
+ export { substituteSettingsTemplate, mergeDenyList, discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
39
48
  export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js';
40
49
  export { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js';
41
50
  export { addCaptureHooks, removeCaptureHooks, hasCaptureHooks } from './capture.js';
@@ -99,6 +108,31 @@ export function formatSweepSummary(report) {
99
108
  }
100
109
  return lines;
101
110
  }
111
+ /**
112
+ * Log each summary line at the severity it carries — the one dispatch every
113
+ * `SummaryLine[]` renderer shares.
114
+ *
115
+ * Exhaustive over `SummaryLine['level']` rather than an `if/else`: a level added
116
+ * to the interface has to be routed here, at compile time, instead of silently
117
+ * degrading to `info` at every call site.
118
+ */
119
+ function logSummaryLines(lines) {
120
+ for (const line of lines) {
121
+ switch (line.level) {
122
+ case 'info':
123
+ p.log.info(line.message);
124
+ break;
125
+ case 'warn':
126
+ p.log.warn(line.message);
127
+ break;
128
+ default: {
129
+ const _exhaustive = line.level;
130
+ void _exhaustive;
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ }
102
136
  /**
103
137
  * Classify the safe-delete installation state based on the installed version
104
138
  * in the user's shell profile.
@@ -162,9 +196,282 @@ export function resolveComplianceInitState(complianceOption, seedFrameworks) {
162
196
  // Re-export formatComplianceSummary from compliance-prompts.ts so existing test imports
163
197
  // (tests/init-logic.test.ts:1615 — imports from '../src/cli/commands/init.js') keep resolving.
164
198
  export { formatComplianceSummary } from './compliance-prompts.js';
199
+ /**
200
+ * Parse the --tracker CLI option into a tracker override.
201
+ *
202
+ * Pure function — no I/O, no side effects; extracted for testability.
203
+ *
204
+ * Returns:
205
+ * {ok: true, value} — override state derived from the option
206
+ * {ok: false, error} — not a registry provider ID (caller handles exit)
207
+ * undefined — option was not supplied; no override
208
+ *
209
+ * There is no `--no-tracker` (decision D-E): `--tracker github` IS the off
210
+ * switch, because `provider:'github'` is the off position. Parsing is strict —
211
+ * reject, never repair — so `--tracker jira-cloud` exits rather than silently
212
+ * selecting jira.
213
+ */
214
+ export function resolveTrackerInitState(trackerOption) {
215
+ if (typeof trackerOption !== 'string')
216
+ return undefined;
217
+ const parsed = parseTrackerId(trackerOption);
218
+ if (!parsed.ok)
219
+ return { ok: false, error: parsed.error };
220
+ return { ok: true, value: { provider: parsed.value } };
221
+ }
222
+ /**
223
+ * The outcome line for a provider that arrived as `--tracker <id>`.
224
+ *
225
+ * D-TRACKER-CLI-SURFACE [PF-029]: the Advanced path prints no end-of-wizard
226
+ * summary, and `--tracker` suppresses the wizard step that would otherwise
227
+ * print one, so the CLI-override arm is the only place the selection can
228
+ * surface there. Without this line `devflow init --advanced --tracker jira`
229
+ * changes the machine-wide provider with nothing on screen — the same
230
+ * unreachable-step failure the wizard gate exists to prevent, arrived at from
231
+ * the flag side. Recommended already has its surface in the summary note's
232
+ * Tracker row; both paths or the step is invisible on one.
233
+ *
234
+ * Pure — the caller renders. The summary half is `formatTrackerSummary`, the one
235
+ * spelling every tracker surface shares.
236
+ */
237
+ export function trackerOverrideMessage(provider) {
238
+ return {
239
+ level: provider === DEFAULT_TRACKER_PROVIDER ? 'info' : 'success',
240
+ text: `Tracker: ${formatTrackerSummary(provider)}`,
241
+ };
242
+ }
243
+ /** The real adapter — the ONE binding of each owner into the init lifecycle. */
244
+ export function buildTrackerLifecycleIO() {
245
+ return {
246
+ writeManifest,
247
+ convergeArtifacts: (claudeDir, warn) => convergeTrackerArtifacts({ claudeDir, warn }),
248
+ rearmInference: rearmTrackerInference,
249
+ applySentinel: applyTrackerSentinel,
250
+ };
251
+ }
252
+ /**
253
+ * Persist the installation manifest, then converge the tracker artifacts against
254
+ * the provider that was actually persisted.
255
+ *
256
+ * D-TRACKER-CONVERGE: the manifest write and the tracker file-lifecycle owners
257
+ * are ONE unit because their relative order is the invariant, not an
258
+ * implementation detail (PF-015). The manifest write is explicitly failable —
259
+ * init must not abort on it — so converging the sentinel ahead of it leaves the
260
+ * two disagreeing in both directions: github→jira writes a sentinel naming a
261
+ * provider the manifest never records, and jira→github removes the sentinel while
262
+ * the manifest stays on jira. Writing first and gating the owners on
263
+ * `manifestWritten` makes them converge all-or-none, in the same order as the
264
+ * sibling `devflow tracker --set` (src/cli/commands/tracker.ts): persist → rearm →
265
+ * sentinel.
266
+ *
267
+ * The provider is read from `manifestData.features.tracker.provider` rather than
268
+ * taken as a separate argument, so there is exactly one binding and the sentinel
269
+ * cannot name a value other than the one on disk.
270
+ *
271
+ * Every step reports rather than aborts (PF-009's isolation posture): a
272
+ * feature-state change must never fail `devflow init`.
273
+ */
274
+ export async function persistManifestThenConvergeTracker(opts) {
275
+ const { devflowDir, claudeDir, manifestData, io } = opts;
276
+ const provider = manifestData.features.tracker.provider;
277
+ const messages = [];
278
+ // The gate. Non-fatal for the install (which has already succeeded) but
279
+ // decisive for the tracker artifacts: an unpersisted selection converges none
280
+ // of them, so the on-disk state stays internally consistent and the next
281
+ // `devflow init` retries the whole transition from an unchanged starting point.
282
+ try {
283
+ await io.writeManifest(devflowDir, manifestData);
284
+ }
285
+ catch (error) {
286
+ messages.push({
287
+ level: 'warn',
288
+ text: `Failed to write installation manifest (install succeeded): ${error instanceof Error ? error.message : error}`,
289
+ });
290
+ messages.push({
291
+ level: 'warn',
292
+ text: `Tracker selection (${provider}) was not persisted — the sentinel and attempt counters ` +
293
+ `are unchanged. Re-run devflow init, or devflow tracker --set ${provider}.`,
294
+ });
295
+ return { manifestWritten: false, converged: false, agent: 'unchanged', messages };
296
+ }
297
+ // The Tracker agent file — installed on every machine (D-INSTALL-ALL-PROVIDERS),
298
+ // because a repository can select a provider the machine never did.
299
+ const agentWarnings = [];
300
+ const artifacts = await io.convergeArtifacts(claudeDir, (msg) => agentWarnings.push(msg));
301
+ for (const text of agentWarnings)
302
+ messages.push({ level: 'warn', text });
303
+ // D-TRACKER-PARALLEL: the two owners touch disjoint files — the attempt
304
+ // counters and the sentinel — depend on nothing the other writes, and both
305
+ // report through TrackerResult instead of throwing (PF-014), so they run
306
+ // concurrently and their warnings are pushed in a fixed order regardless of
307
+ // which settles first.
308
+ const [rearm, sentinel] = await Promise.all([
309
+ // [DR-22] The documented re-arm path: devflow init resets the attempt counters
310
+ // so a previously-capped inference gets another five tries.
311
+ io.rearmInference(devflowDir),
312
+ // [DR-10] Converge the sentinel onto the persisted machine provider: its name
313
+ // for jira/linear, removed for github. The SessionStart hook reads it with a
314
+ // builtin, which is what keeps the machine provider free of forks.
315
+ io.applySentinel(devflowDir, provider),
316
+ ]);
317
+ if (!rearm.ok)
318
+ messages.push({ level: 'warn', text: rearm.error });
319
+ if (!sentinel.ok)
320
+ messages.push({ level: 'warn', text: sentinel.error });
321
+ return {
322
+ manifestWritten: true,
323
+ converged: artifacts.converged && sentinel.ok,
324
+ agent: artifacts.agent,
325
+ messages,
326
+ };
327
+ }
328
+ /**
329
+ * Drain this repo's memory and learning queues for each feature init switched
330
+ * off, so stale turns are not processed on a future re-enable — the same drains
331
+ * `devflow memory --disable` and `devflow learning --disable` perform. Other
332
+ * repos' queues are inert: every gate reads the machine-wide switch, so nothing
333
+ * appends to or processes them while the feature is off.
334
+ *
335
+ * D-INIT-DRAIN-AFTER-SWITCH: the drain runs only AFTER the manifest holding the
336
+ * switch is persisted (`manifestWritten`), the order the standalone toggles use
337
+ * (write the switch, then drain). The capture hooks read the manifest on every
338
+ * turn, so draining first left a window — the whole install — in which a
339
+ * concurrent session, still reading the old "on", appended turns that then
340
+ * survived the disable. When the manifest write failed the feature is still on
341
+ * everywhere, so its queue is live and is left alone.
342
+ *
343
+ * D-LEDGER-MAIN-WORKTREE: each queue drains where the hooks write it — memory at
344
+ * this checkout's toplevel (`gitRoot`), learning at the ledger root (`ledgerRoot`,
345
+ * getLedgerRoot), which in a linked worktree is the main checkout.
346
+ */
347
+ export async function drainDisabledFeatureQueues(opts, io = { drainMemoryQueue, drainLearningQueue }) {
348
+ if (!opts.manifestWritten)
349
+ return;
350
+ if (!opts.memoryEnabled && opts.gitRoot !== null)
351
+ await io.drainMemoryQueue(opts.gitRoot);
352
+ if (!opts.learningEnabled && opts.ledgerRoot !== null)
353
+ await io.drainLearningQueue(opts.ledgerRoot);
354
+ }
355
+ /**
356
+ * The manifest `devflow init --hud-only` writes. Pure — never mutates `existing`.
357
+ *
358
+ * D-HUD-ONLY-PRESERVE: over a prior install, --hud-only installs the HUD and
359
+ * nothing else, so the manifest keeps every recorded value — plugins, version,
360
+ * installedAt and every feature — and only `features.hud` turns on. The
361
+ * earlier shape rewrote the whole record as a HUD-only fresh install (ambient,
362
+ * memory, learning, knowledge, rules and proxy all `false`, plugins `[]`) while
363
+ * leaving those features' artifacts on disk: the record stopped describing the
364
+ * machine, the next re-init seeded every feature off (ADR-014), and with
365
+ * memory/learning/knowledge switched by the manifest alone
366
+ * (D-FEATURES-NARROW-ONLY) a HUD install would have really disabled them
367
+ * everywhere. `version` is kept too: --hud-only reinstalls no plugin, and a
368
+ * bumped version would make the next init skip the upgrade it still owes.
369
+ *
370
+ * With no prior manifest the result is the fresh HUD-only record: every other
371
+ * feature off, which is what is installed.
372
+ */
373
+ export function buildHudOnlyManifest(existing, version, now) {
374
+ if (existing !== null) {
375
+ return {
376
+ ...existing,
377
+ features: { ...existing.features, hud: true },
378
+ updatedAt: now,
379
+ };
380
+ }
381
+ return {
382
+ version,
383
+ plugins: [],
384
+ scope: 'user',
385
+ features: {
386
+ ambient: false, memory: false, hud: true, knowledge: false,
387
+ learning: false, rules: false, flags: {}, proxy: false,
388
+ compliance: { enabled: false, frameworks: [] },
389
+ // The exported default, not a literal — the one constant every other
390
+ // module reads, so a moved default moves here too.
391
+ tracker: { provider: DEFAULT_TRACKER_PROVIDER },
392
+ },
393
+ installedAt: now,
394
+ updatedAt: now,
395
+ };
396
+ }
397
+ /**
398
+ * Decide what `init --scope <value>` does now that there is one install scope.
399
+ * Pure — the action performs the exit.
400
+ *
401
+ * D-SCOPE-RETIRED: `--scope` is kept as a hidden option so existing scripts keep
402
+ * parsing. `user` (any case) is exactly the no-flag install. `local` is refused
403
+ * before anything is written: the repo-local install wrote `<repo>/.claude` and
404
+ * `<repo>/.devflow` while every hook and prompt read `~/.devflow`, so it never
405
+ * worked, and the only thing left to do with one is remove it. Any other value
406
+ * is refused the same way rather than silently treated as `user`.
407
+ */
408
+ export function resolveRetiredScopeOption(scope) {
409
+ if (scope === undefined || scope.toLowerCase() === 'user')
410
+ return { kind: 'proceed' };
411
+ if (scope.toLowerCase() === 'local') {
412
+ return {
413
+ kind: 'refuse',
414
+ message: 'Project-local installs are no longer supported: Devflow installs machine-wide only. ' +
415
+ 'To remove an old project-local install, run `devflow uninstall --scope local` to clean up.',
416
+ };
417
+ }
418
+ return {
419
+ kind: 'refuse',
420
+ message: `Unknown --scope value "${scope}": Devflow installs machine-wide only (omit --scope).`,
421
+ };
422
+ }
423
+ /** The line init prints after removing old hook log folders (D-LOG-DIR-CAP). Pure. */
424
+ export function formatLogPruneLine(report) {
425
+ const base = `Removed ${report.removed} old hook log folder${report.removed === 1 ? '' : 's'} ` +
426
+ `(keeping the ${MAX_HOOK_LOG_DIRS} most recent)`;
427
+ return report.overCap > 0 ? `${base}; ${report.overCap} more go on the next init` : base;
428
+ }
429
+ /**
430
+ * The warning init prints when the running CLI is older than the one that last
431
+ * installed this machine, or null. Pure.
432
+ *
433
+ * D-INIT-DOWNGRADE-WARN: a downgrade is allowed — an older CLI installs a
434
+ * consistent older devflow — but never silent: its install sweeps every skill,
435
+ * agent and command the newer version added as an orphan, and settings the newer
436
+ * version wrote may mean nothing to it. The warning names both versions and how
437
+ * to get back; nothing blocks.
438
+ */
439
+ export function formatDowngradeWarning(upgrade, version) {
440
+ if (!upgrade.isDowngrade || upgrade.previousVersion === null)
441
+ return null;
442
+ return `Downgrading: this machine was installed by devflow v${upgrade.previousVersion}, newer than this CLI (v${version}). ` +
443
+ 'Assets only the newer version ships will be removed. To keep them, run the newer CLI instead ' +
444
+ '(npx devflow-kit@latest init).';
445
+ }
446
+ /**
447
+ * The warning init prints when it leaves a repository's `.devflow/config.json`
448
+ * alone (D-CONFIG-NO-REPAIR). Pure.
449
+ */
450
+ export function formatManagedConfigWriteError(error) {
451
+ switch (error.kind) {
452
+ case 'malformed':
453
+ return `${error.path} is not a valid config (not a JSON object, or a key appears twice) — left unchanged. ` +
454
+ 'Fix it by hand; until then devflow treats it as unreadable.';
455
+ case 'unreadable':
456
+ return `${error.path} could not be read (${error.detail}) — left unchanged.`;
457
+ case 'write-failed':
458
+ return `Could not write ${error.path}: ${error.detail}`;
459
+ default: {
460
+ const exhaustive = error;
461
+ return exhaustive;
462
+ }
463
+ }
464
+ }
465
+ /**
466
+ * The git repositories Claude has worked in, as project roots: every
467
+ * history.jsonl project with a `.git`, less any rooted at HOME (D-INIT-NOT-HOME).
468
+ */
469
+ async function discoverRepoRoots(claudeDir, homeDir) {
470
+ return withoutHomeRoots(await discoverProjectGitRoots(claudeDir), homeDir);
471
+ }
165
472
  export const initCommand = new Command('init')
166
473
  .description('Initialize Devflow for Claude Code')
167
- .option('--scope <type>', 'Installation scope: user or local (project-only)', /^(user|local)$/i)
474
+ .addOption(new Option('--scope <type>', 'Retired: Devflow installs machine-wide only').hideHelp())
168
475
  .option('--verbose', 'Show detailed installation output')
169
476
  .option('--plugin <names>', 'Install specific plugin(s), comma-separated (e.g., implement,code-review)')
170
477
  .option('--ambient', 'Enable ambient mode (orchestrator charter + plan handoff)')
@@ -182,7 +489,8 @@ export const initCommand = new Command('init')
182
489
  .option('--proxy', 'Enable external model routing (GPT models via your OpenAI/Codex subscription)')
183
490
  .option('--no-proxy', 'Disable external model routing')
184
491
  .option('--compliance <list>', 'Enable compliance with comma-separated framework IDs (e.g., gdpr,hipaa)')
185
- .option('--no-compliance', 'Disable compliance (artifacts removed; frameworks remembered for re-enable)')
492
+ .option('--no-compliance', 'Disable compliance (removes the rule; the skill and framework references stay installed; frameworks remembered for re-enable)')
493
+ .option('--tracker <id>', 'The machine\'s default issue tracker provider: github, jira, or linear')
186
494
  .option('--security <mode>', 'Security deny list location: user, managed, or none', /^(user|managed|none)$/i)
187
495
  .option('--hud-only', 'Install only the HUD (no plugins, hooks, or extras)')
188
496
  .option('--recommended', 'Apply recommended defaults after plugin selection (skip advanced prompts)')
@@ -208,30 +516,21 @@ export const initCommand = new Command('init')
208
516
  p.log.error('--reset and --plugin are mutually exclusive. Use --reset alone to restore defaults, or --plugin to update a specific plugin.');
209
517
  process.exit(1);
210
518
  }
211
- // Determine installation scope
212
- let scope = 'user';
213
- if (options.hudOnly) {
214
- // --hud-only: skip scope prompt, always user scope
215
- scope = 'user';
216
- }
217
- else if (options.scope) {
218
- const normalizedScope = options.scope.toLowerCase();
219
- if (normalizedScope !== 'user' && normalizedScope !== 'local') {
220
- p.log.error('Invalid scope. Use "user" or "local"');
221
- process.exit(1);
222
- }
223
- scope = normalizedScope;
519
+ // D-SCOPE-RETIRED: refuse a retired --scope value before anything is written.
520
+ const scopeDecision = resolveRetiredScopeOption(options.scope);
521
+ if (scopeDecision.kind === 'refuse') {
522
+ p.log.error(scopeDecision.message);
523
+ process.exit(1);
224
524
  }
225
- else if (!process.stdin.isTTY) {
226
- p.log.info('Non-interactive mode detected, using scope: user');
227
- scope = 'user';
525
+ // The install locations, resolved once. They fail only with no home directory.
526
+ const resolvedPaths = resolveInstallationPaths();
527
+ if (!resolvedPaths.ok) {
528
+ p.log.error(resolvedPaths.error);
529
+ process.exit(1);
228
530
  }
531
+ const { homeDir, claudeDir, devflowDir } = resolvedPaths.value;
229
532
  // --hud-only: install only HUD (skip plugins, hooks, extras)
230
533
  if (options.hudOnly) {
231
- // Resolve paths
232
- const paths = await getInstallationPaths(scope);
233
- const claudeDir = paths.claudeDir;
234
- const devflowDir = paths.devflowDir;
235
534
  // Save HUD config
236
535
  const existingHud = loadHudConfig();
237
536
  saveHudConfig({ enabled: true, detail: existingHud.detail });
@@ -246,7 +545,7 @@ export const initCommand = new Command('init')
246
545
  content = '{}';
247
546
  }
248
547
  const updated = addHudStatusLine(content, devflowDir);
249
- await fs.writeFile(settingsPath, updated, 'utf-8');
548
+ await writeSettingsFileAtomic(settingsPath, updated);
250
549
  }
251
550
  catch (error) {
252
551
  p.log.error(`Failed to update settings: ${error instanceof Error ? error.message : error}`);
@@ -261,28 +560,15 @@ export const initCommand = new Command('init')
261
560
  p.log.error(`Failed to install HUD scripts: ${error instanceof Error ? error.message : error}`);
262
561
  process.exit(1);
263
562
  }
264
- // Read existing manifest to preserve user-set compliance state (disable-keeps-frameworks
265
- // contract: the hud-only path must not erase frameworks the user previously selected).
563
+ // Read the existing manifest: over a prior install, --hud-only touches only
564
+ // the HUD (D-HUD-ONLY-PRESERVE in buildHudOnlyManifest).
266
565
  let existingHudManifest = null;
267
566
  try {
268
567
  existingHudManifest = await readManifest(devflowDir);
269
568
  }
270
569
  catch { /* absent on fresh install — existingHudManifest stays null */ }
271
- // Write minimal manifest
272
- const now = new Date().toISOString();
273
570
  try {
274
- await writeManifest(devflowDir, {
275
- version,
276
- plugins: [],
277
- scope,
278
- features: {
279
- ambient: false, memory: false, hud: true, knowledge: false,
280
- learning: false, rules: false, flags: {}, proxy: false,
281
- compliance: existingHudManifest?.features.compliance ?? { enabled: false, frameworks: [] },
282
- },
283
- installedAt: now,
284
- updatedAt: now,
285
- });
571
+ await writeManifest(devflowDir, buildHudOnlyManifest(existingHudManifest, version, new Date().toISOString()));
286
572
  }
287
573
  catch { /* non-fatal */ }
288
574
  p.log.success('HUD installed');
@@ -290,28 +576,30 @@ export const initCommand = new Command('init')
290
576
  p.outro(color.green('HUD-only install complete.'));
291
577
  return;
292
578
  }
293
- // ── Hoist reads: resolve paths early to compute InitSeed for pre-seeded prompts (Phase 4) ──
294
- // Best-effort: if path resolution fails here, seed falls back to fresh-install defaults.
295
- // The authoritative error gate for failed path resolution remains at the install-begins
296
- // spinner (see "Resolving paths" below). Hoisted above multiselect so Phase 4 can
297
- // pre-seed plugin/flag/feature prompts.
579
+ // ── Hoisted reads: the prior state that seeds the prompts (InitSeed, Phase 4) ──
298
580
  let existingManifest = null;
299
- let earlyProjectConfig = null;
581
+ try {
582
+ existingManifest = await readManifest(devflowDir);
583
+ }
584
+ catch { /* unreadable manifest — seeded as a fresh install */ }
585
+ // D-INIT-NOT-HOME (same-location.ts): a repository rooted at HOME is no
586
+ // project, so init treats it as no repository — no .devflow/config.json (that
587
+ // would be the machine root's), no .claudeignore, no .gitignore block, no
588
+ // per-project migration or queue drain.
589
+ const cwdGitRoot = await getGitRoot();
590
+ const homeRootedRepo = cwdGitRoot !== null && await isSameLocation(cwdGitRoot, homeDir);
591
+ const gitRoot = homeRootedRepo ? null : cwdGitRoot;
592
+ if (homeRootedRepo) {
593
+ p.log.info('This git repository is rooted at your home directory, so init writes no per-repository files here.');
594
+ }
595
+ const earlyProjectConfig = gitRoot
596
+ ? await readConfigIfPresent(gitRoot)
597
+ : null;
300
598
  let earlySettingsJson = null;
301
- let earlyGitRoot = null;
302
599
  try {
303
- const earlyPaths = await getInstallationPaths(scope);
304
- existingManifest = await readManifest(earlyPaths.devflowDir);
305
- earlyGitRoot = earlyPaths.gitRoot ?? await getGitRoot();
306
- if (earlyGitRoot) {
307
- earlyProjectConfig = await readConfigIfPresent(earlyGitRoot);
308
- }
309
- try {
310
- earlySettingsJson = await fs.readFile(path.join(earlyPaths.claudeDir, 'settings.json'), 'utf-8');
311
- }
312
- catch { /* settings.json absent — treated as empty */ }
600
+ earlySettingsJson = await fs.readFile(path.join(claudeDir, 'settings.json'), 'utf-8');
313
601
  }
314
- catch { /* path resolution deferred to install-begins gate */ }
602
+ catch { /* settings.json absent — treated as empty */ }
315
603
  // --reset: factory reset — treat as a fresh install for all seeding and routing decisions.
316
604
  // The REAL existingManifest / earlySettingsJson are still used below for installedAt
317
605
  // preservation, upgrade messaging, and security deny-state detection. resolveResetGatedInputs
@@ -319,7 +607,7 @@ export const initCommand = new Command('init')
319
607
  // registry defaults — including viewMode 'default' (an externally-set /focus in settings.json
320
608
  // must not survive a factory reset).
321
609
  const { seedManifest, seedConfig, seedSettings } = resolveResetGatedInputs(!!options.reset, existingManifest, earlyProjectConfig, earlySettingsJson ?? '');
322
- const seed = resolveInitSeed(seedManifest, seedConfig, seedSettings, DEVFLOW_PLUGINS);
610
+ const seed = resolveInitSeed(seedManifest, seedSettings, DEVFLOW_PLUGINS);
323
611
  // Early validation: parse --compliance <list> at the boundary before any prompts (PF-parse-at-boundary).
324
612
  // options.compliance: string → --compliance <list>; false → --no-compliance; undefined → not passed
325
613
  let cliComplianceOverride;
@@ -333,6 +621,20 @@ export const initCommand = new Command('init')
333
621
  cliComplianceOverride = complianceStateResult.value;
334
622
  }
335
623
  }
624
+ // Early validation: parse --tracker <id> at the boundary before any prompts.
625
+ // Strict — reject, never repair — so a typo'd provider exits here rather than
626
+ // installing mechanics for a tracker the user did not name.
627
+ let cliTrackerOverride;
628
+ {
629
+ const trackerStateResult = resolveTrackerInitState(options.tracker);
630
+ if (trackerStateResult !== undefined) {
631
+ if (!trackerStateResult.ok) {
632
+ p.log.error(trackerStateResult.error);
633
+ process.exit(1);
634
+ }
635
+ cliTrackerOverride = trackerStateResult.value;
636
+ }
637
+ }
336
638
  // Select plugins to install
337
639
  let selectedPlugins = [];
338
640
  if (options.plugin) {
@@ -441,7 +743,8 @@ export const initCommand = new Command('init')
441
743
  // When no --plugin flag is given and a manifest exists, the seed carries the prior
442
744
  // selection (existing plugins ∪ new non-optional plugins not yet in knownPlugins).
443
745
  // Fresh non-interactive installs (no manifest) fall through to the default path
444
- // in pluginsToInstall which installs all non-optional plugins.
746
+ // in resolvePluginsToInstall: every non-optional plugin, with devflow-ambient
747
+ // following the ambient switch (D-AMBIENT-FOLLOWS-SWITCH).
445
748
  if (!options.plugin && !process.stdin.isTTY && seedManifest !== null) {
446
749
  selectedPlugins = [...seed.workflowPlugins, ...seed.languagePlugins];
447
750
  }
@@ -506,13 +809,16 @@ export const initCommand = new Command('init')
506
809
  // CLI override applied below in both Recommended and Advanced paths.
507
810
  let complianceEnabled = seed.features.compliance.enabled;
508
811
  let complianceFrameworks = seed.features.compliance.frameworks;
812
+ // tracker: manifest-group (like proxy and compliance); seed from prior manifest.
813
+ // CLI override applied below in both Recommended and Advanced paths.
814
+ let trackerProvider = seed.features.tracker.provider;
509
815
  let enabledFlags = { ...seed.flags };
510
816
  // viewModeExplicit: true when --reset is passed; signals resolveFinalViewMode to let the
511
817
  // seed-time view-mode win over an externally-set value in settings.json.
512
818
  // --reset empties the settings snapshot via resolveResetGatedInputs so seed.flags['view-mode']
513
819
  // collapses to 'default', and explicit=true makes it take effect at settings write time.
514
820
  let viewModeExplicit = !!options.reset;
515
- let claudeignoreEnabled = !!earlyGitRoot;
821
+ let claudeignoreEnabled = !!gitRoot;
516
822
  let discoveredProjects = [];
517
823
  let safeDeleteAction = 'skip';
518
824
  let safeDeleteBlock = null;
@@ -520,6 +826,47 @@ export const initCommand = new Command('init')
520
826
  // The final value is written to the manifest and consumed by the dedicated security step.
521
827
  let securityMode = 'user'; // placeholder; overwritten below by resolve
522
828
  let managedSettingsConfirmed = false;
829
+ /**
830
+ * Run the tracker wizard step for one wizard path.
831
+ *
832
+ * D-TRACKER-CALLSHAPE: both paths share one gate (shouldRunTrackerStep), one
833
+ * cancel idiom and one prompt adapter; they differ only in the mode they
834
+ * declare, the provider they seed from, and whether the step's own outcome
835
+ * line is emitted. Holding all three differences as parameters keeps the
836
+ * shared half single-sourced, so the paths cannot drift the way two
837
+ * hand-copied call sites do.
838
+ *
839
+ * Returns the chosen state, or undefined when the gate declined to run — the
840
+ * caller then owns the CLI-override fallback, which is the only other way the
841
+ * provider can change on that path.
842
+ */
843
+ const runTrackerStepAt = async (mode, seedProvider, emitMessages) => {
844
+ if (!shouldRunTrackerStep({
845
+ mode,
846
+ modePromptShown,
847
+ isTTY: process.stdin.isTTY,
848
+ hasCliOverride: cliTrackerOverride !== undefined,
849
+ })) {
850
+ return undefined;
851
+ }
852
+ const trackerStep = await runTrackerStep({
853
+ seed: { provider: seedProvider },
854
+ prompts: buildClackTrackerPrompts(),
855
+ });
856
+ if (trackerStep.kind === 'cancelled') {
857
+ p.cancel('Installation cancelled.');
858
+ process.exit(0);
859
+ }
860
+ if (emitMessages) {
861
+ for (const msg of trackerStep.messages) {
862
+ if (msg.level === 'success')
863
+ p.log.success(msg.text);
864
+ else
865
+ p.log.info(msg.text);
866
+ }
867
+ }
868
+ return trackerStep.state;
869
+ };
523
870
  // Safe-delete detection (both paths need this)
524
871
  const platform = detectPlatform();
525
872
  const shell = detectShell();
@@ -552,6 +899,13 @@ export const initCommand = new Command('init')
552
899
  // Step messages not emitted here — the Recommended summary note (below) already
553
900
  // prints the Compliance line from complianceSummary via formatComplianceSummary.
554
901
  }
902
+ // Tracker wizard step — same gate as compliance, so both wizard paths are
903
+ // governed by the one documented gate table (AC-3.6). Runs only when the
904
+ // Setup-mode prompt actually ran, so --recommended and !isTTY stay promptless.
905
+ // emitMessages=false: the Recommended summary note (below) prints the
906
+ // Tracker line via formatTrackerSummary, so the step's own outcome line
907
+ // would be a duplicate.
908
+ const wizardTracker = await runTrackerStepAt('recommended', seed.features.tracker.provider, false);
555
909
  // No attribution step here: the suppress-attribution question is Advanced-only (D27).
556
910
  // Recommended silently carries the seeded value in enabledFlags — fresh installs get
557
911
  // the registry default (off), re-inits get prior state. See shouldRunAttributionStep.
@@ -567,6 +921,8 @@ export const initCommand = new Command('init')
567
921
  rules: options.rules,
568
922
  proxy: options.proxy,
569
923
  compliance: cliComplianceOverride ?? wizardCompliance,
924
+ // Precedence: cliOverride ?? wizardResult ?? seed (applyCliToggles supplies the seed arm).
925
+ tracker: cliTrackerOverride ?? wizardTracker,
570
926
  });
571
927
  ambientEnabled = effectiveFeatures.ambient;
572
928
  memoryEnabled = effectiveFeatures.memory;
@@ -577,6 +933,7 @@ export const initCommand = new Command('init')
577
933
  proxyEnabled = effectiveFeatures.proxy;
578
934
  complianceEnabled = effectiveFeatures.compliance.enabled;
579
935
  complianceFrameworks = effectiveFeatures.compliance.frameworks;
936
+ trackerProvider = effectiveFeatures.tracker.provider;
580
937
  // enabledFlags is already initialised to seed.flags above.
581
938
  // Compute safe-delete block synchronously so we know whether to fetch installed version
582
939
  if (profilePath && safeDeleteAvailable) {
@@ -584,10 +941,10 @@ export const initCommand = new Command('init')
584
941
  safeDeleteBlock = generateSafeDeleteBlock(shell, process.platform, trashCmd);
585
942
  }
586
943
  // Run independent I/O in parallel: project discovery + safe-delete version check
587
- const needsDiscovery = earlyGitRoot && scope === 'user';
944
+ const needsDiscovery = gitRoot !== null;
588
945
  const needsVersionCheck = safeDeleteBlock && profilePath;
589
946
  const [discoveredResult, installedVersionResult] = await Promise.all([
590
- needsDiscovery ? discoverProjectGitRoots() : Promise.resolve([]),
947
+ needsDiscovery ? discoverRepoRoots(claudeDir, homeDir) : Promise.resolve([]),
591
948
  needsVersionCheck ? getInstalledVersion(profilePath) : Promise.resolve(0),
592
949
  ]);
593
950
  discoveredProjects = discoveredResult;
@@ -612,6 +969,10 @@ export const initCommand = new Command('init')
612
969
  `Knowledge bases: ${knowledgeEnabled ? 'enabled' : 'disabled'}`,
613
970
  `Ext model routing: ${proxyEnabled ? 'enabled' : 'disabled'}`,
614
971
  `Compliance: ${complianceSummary}`,
972
+ // Recommended emits no per-step outcome lines, so this summary row is the
973
+ // tracker step's ONLY surface on this path — both surfaces or it is
974
+ // invisible on one path.
975
+ `Tracker: ${formatTrackerSummary(trackerProvider)}`,
615
976
  `View mode: ${readViewMode(enabledFlags)}`,
616
977
  `Claude Code flags: ${defaultFlagCount} configured`,
617
978
  `${claudeignoreEnabled ? '.claudeignore: created' : ''}`,
@@ -657,7 +1018,8 @@ export const initCommand = new Command('init')
657
1018
  'compaction. Clear your session at any point and resume right\n' +
658
1019
  'where you left off.\n\n' +
659
1020
  'Runs a background agent on session stop that consumes additional\n' +
660
- 'tokens. Consider skipping if token usage is a concern.', 'Working Memory');
1021
+ 'tokens. Consider skipping if token usage is a concern.\n' +
1022
+ 'Applies to every project.', 'Working Memory');
661
1023
  const memoryChoice = await p.confirm({
662
1024
  message: 'Enable working memory? (Recommended)',
663
1025
  initialValue: seed.features.memory,
@@ -690,7 +1052,8 @@ export const initCommand = new Command('init')
690
1052
  else {
691
1053
  p.note('Per-feature knowledge bases capture cross-cutting patterns,\n' +
692
1054
  'conventions, and gotchas. Created and updated automatically\n' +
693
- 'when workflows touch a documented area (write-through model).', 'Feature Knowledge Bases');
1055
+ 'when workflows touch a documented area (write-through model).\n' +
1056
+ 'Applies to every project.', 'Feature Knowledge Bases');
694
1057
  const knowledgeChoice = await p.confirm({
695
1058
  message: 'Enable feature knowledge bases? (Recommended)',
696
1059
  initialValue: seed.features.knowledge,
@@ -707,7 +1070,7 @@ export const initCommand = new Command('init')
707
1070
  else {
708
1071
  p.note('Detects architectural decisions and pitfalls from your session\n' +
709
1072
  'dialogs. Runs a background agent on session stop that consumes\n' +
710
- 'additional tokens.', 'Learning (Decision/Pitfall Tracking)');
1073
+ 'additional tokens. Applies to every project.', 'Learning (Decision/Pitfall Tracking)');
711
1074
  const learningChoice = await p.confirm({
712
1075
  message: 'Enable learning? (Recommended)',
713
1076
  initialValue: seed.features.learning,
@@ -797,6 +1160,28 @@ export const initCommand = new Command('init')
797
1160
  // No third case in practice: on this path the predicate only returns false for a
798
1161
  // CLI override (isTTY is guaranteed true by the non-TTY guard above). If it ever
799
1162
  // did, the seed values assigned at declaration stand — which is the right default.
1163
+ // Tracker feature (after compliance, before attribution). Gated by the same
1164
+ // shouldRunTrackerStep predicate as the Recommended path, so the documented
1165
+ // gate table is the single authority for both and they cannot drift.
1166
+ // This call site is the one that matters on RE-INIT: re-init is Advanced-only
1167
+ // by construction, so a Recommended-only wiring would be dead there.
1168
+ // emitMessages=true: Advanced has no end-of-wizard summary recap, so the
1169
+ // step's outcome line is this path's ONLY surface — mandatory, not decorative.
1170
+ const advancedTracker = await runTrackerStepAt('advanced', trackerProvider, true);
1171
+ if (advancedTracker !== undefined) {
1172
+ trackerProvider = advancedTracker.provider;
1173
+ }
1174
+ else if (cliTrackerOverride !== undefined) {
1175
+ // --tracker passed explicitly — honour without prompting, and say so.
1176
+ // The gate declined the step, and this path has no summary recap, so
1177
+ // this line is the selection's ONLY surface (D-TRACKER-CLI-SURFACE).
1178
+ trackerProvider = cliTrackerOverride.provider;
1179
+ const overrideLine = trackerOverrideMessage(trackerProvider);
1180
+ if (overrideLine.level === 'success')
1181
+ p.log.success(overrideLine.text);
1182
+ else
1183
+ p.log.info(overrideLine.text);
1184
+ }
800
1185
  // Attribution feature (after compliance, before flags). This is the ONLY call site —
801
1186
  // the attribution question is Advanced-only (D27); the Recommended path never asks and
802
1187
  // silently carries the seeded value. The gate stays an explicit predicate call so the
@@ -842,45 +1227,29 @@ export const initCommand = new Command('init')
842
1227
  p.log.info(`Flags: ${activeCount} active — customize any time with 'devflow flags'`);
843
1228
  }
844
1229
  // .claudeignore prompt
845
- if (earlyGitRoot) {
846
- if (scope === 'user') {
847
- discoveredProjects = await discoverProjectGitRoots();
848
- p.note('Scans all projects Claude has worked on and creates a\n' +
849
- '.claudeignore in each git repository. Excludes secrets,\n' +
850
- 'API keys, dependencies, and build artifacts from context.', '.claudeignore');
851
- if (discoveredProjects.length > 0) {
852
- const maxShow = 5;
853
- const projectLines = discoveredProjects.slice(0, maxShow).join('\n');
854
- const overflow = discoveredProjects.length > maxShow
855
- ? `\n... (${discoveredProjects.length - maxShow} more)`
856
- : '';
857
- p.note(projectLines + overflow, `Discovered ${discoveredProjects.length} projects`);
858
- const claudeignoreChoice = await p.confirm({
859
- message: `Install .claudeignore to ${discoveredProjects.length} projects? (Recommended)`,
860
- initialValue: true,
861
- });
862
- if (p.isCancel(claudeignoreChoice)) {
863
- p.cancel('Installation cancelled.');
864
- process.exit(0);
865
- }
866
- claudeignoreEnabled = claudeignoreChoice;
867
- }
868
- else {
869
- const claudeignoreChoice = await p.confirm({
870
- message: 'Create .claudeignore? (Recommended)',
871
- initialValue: true,
872
- });
873
- if (p.isCancel(claudeignoreChoice)) {
874
- p.cancel('Installation cancelled.');
875
- process.exit(0);
876
- }
877
- claudeignoreEnabled = claudeignoreChoice;
1230
+ if (gitRoot) {
1231
+ discoveredProjects = await discoverRepoRoots(claudeDir, homeDir);
1232
+ p.note('Scans all projects Claude has worked on and creates a\n' +
1233
+ '.claudeignore in each git repository. Excludes secrets,\n' +
1234
+ 'API keys, dependencies, and build artifacts from context.', '.claudeignore');
1235
+ if (discoveredProjects.length > 0) {
1236
+ const maxShow = 5;
1237
+ const projectLines = discoveredProjects.slice(0, maxShow).join('\n');
1238
+ const overflow = discoveredProjects.length > maxShow
1239
+ ? `\n... (${discoveredProjects.length - maxShow} more)`
1240
+ : '';
1241
+ p.note(projectLines + overflow, `Discovered ${discoveredProjects.length} projects`);
1242
+ const claudeignoreChoice = await p.confirm({
1243
+ message: `Install .claudeignore to ${discoveredProjects.length} projects? (Recommended)`,
1244
+ initialValue: true,
1245
+ });
1246
+ if (p.isCancel(claudeignoreChoice)) {
1247
+ p.cancel('Installation cancelled.');
1248
+ process.exit(0);
878
1249
  }
1250
+ claudeignoreEnabled = claudeignoreChoice;
879
1251
  }
880
1252
  else {
881
- p.note('Creates a .claudeignore in this project that excludes\n' +
882
- 'secrets, API keys, dependencies, and build artifacts from\n' +
883
- 'Claude\'s context window.', '.claudeignore');
884
1253
  const claudeignoreChoice = await p.confirm({
885
1254
  message: 'Create .claudeignore? (Recommended)',
886
1255
  initialValue: true,
@@ -921,8 +1290,8 @@ export const initCommand = new Command('init')
921
1290
  }
922
1291
  }
923
1292
  }
924
- // Security deny list placement (user scope + TTY only)
925
- if (scope === 'user' && process.stdin.isTTY) {
1293
+ // Security deny list placement (TTY only)
1294
+ if (process.stdin.isTTY) {
926
1295
  p.note('Devflow includes a security deny list that blocks dangerous\n' +
927
1296
  'commands (rm -rf, sudo, eval, etc). It can be installed as a\n' +
928
1297
  'read-only system file or in your editable settings.json.', 'Security Deny List');
@@ -965,34 +1334,23 @@ export const initCommand = new Command('init')
965
1334
  // ╭──────────────────────────────────────────────────────────╮
966
1335
  // │ All prompts collected — installation begins │
967
1336
  // ╰──────────────────────────────────────────────────────────╯
1337
+ const upgrade = existingManifest ? detectUpgrade(version, existingManifest.version) : null;
1338
+ const downgradeWarning = upgrade === null ? null : formatDowngradeWarning(upgrade, version);
1339
+ if (downgradeWarning !== null)
1340
+ p.log.warn(downgradeWarning);
968
1341
  const s = p.spinner();
969
- s.start('Resolving paths');
970
- // Get installation paths
971
- let claudeDir;
972
- let devflowDir;
973
- let gitRoot = null;
974
- try {
975
- const paths = await getInstallationPaths(scope);
976
- claudeDir = paths.claudeDir;
977
- devflowDir = paths.devflowDir;
978
- gitRoot = paths.gitRoot ?? earlyGitRoot;
1342
+ s.start('Installing');
1343
+ if (upgrade?.isUpgrade) {
1344
+ s.message(`Upgrading from v${upgrade.previousVersion} to v${version}`);
979
1345
  }
980
- catch (error) {
981
- s.stop('Path resolution failed');
982
- p.log.error(`Path configuration error: ${error instanceof Error ? error.message : error}`);
983
- process.exit(1);
984
- }
985
- // existingManifest was read early above (hoisted for seed computation); use it here for upgrade detection
986
- if (existingManifest) {
987
- const upgrade = detectUpgrade(version, existingManifest.version);
988
- if (upgrade.isUpgrade) {
989
- s.message(`Upgrading from v${upgrade.previousVersion} to v${version}`);
990
- }
991
- else if (upgrade.isSameVersion) {
992
- s.message('Reinstalling same version');
993
- }
1346
+ else if (upgrade?.isSameVersion) {
1347
+ s.message('Reinstalling same version');
994
1348
  }
995
1349
  // Detect current deny list state in user settings (read-only; write happens in security step)
1350
+ // Whether the managed settings file holds a Devflow deny entry — the security
1351
+ // step's `none` branch removes it only then (and only then stops the spinner
1352
+ // for a possible sudo prompt).
1353
+ let managedDenyDetected = false;
996
1354
  {
997
1355
  const userSettingsJson = earlySettingsJson;
998
1356
  let managedExists = false;
@@ -1005,6 +1363,7 @@ export const initCommand = new Command('init')
1005
1363
  }
1006
1364
  catch { /* absent or unsupported platform */ }
1007
1365
  const detected = detectDenyState(userSettingsJson, managedExists, managedContentJson);
1366
+ managedDenyDetected = detected.managed;
1008
1367
  const flagValue = options.security;
1009
1368
  const manifestMode = existingManifest?.features.security;
1010
1369
  const resolution = resolveSecurityAction(flagValue, manifestMode, detected, process.stdin.isTTY);
@@ -1033,44 +1392,30 @@ export const initCommand = new Command('init')
1033
1392
  }
1034
1393
  // Validate target directory
1035
1394
  s.message('Validating target directory');
1036
- if (scope === 'local') {
1037
- try {
1038
- await fs.mkdir(claudeDir, { recursive: true });
1039
- }
1040
- catch (error) {
1041
- s.stop('Installation failed');
1042
- p.log.error(`Failed to create ${claudeDir}: ${error}`);
1043
- process.exit(1);
1044
- }
1395
+ try {
1396
+ await fs.access(claudeDir);
1045
1397
  }
1046
- else {
1047
- try {
1048
- await fs.access(claudeDir);
1049
- }
1050
- catch {
1051
- s.stop('Installation failed');
1052
- p.log.error(`Claude Code not detected at ${claudeDir}`);
1053
- p.log.info('Install from: https://claude.ai/download');
1054
- process.exit(1);
1055
- }
1398
+ catch {
1399
+ s.stop('Installation failed');
1400
+ p.log.error(`Claude Code not detected at ${claudeDir}`);
1401
+ p.log.info('Install from: https://claude.ai/download');
1402
+ process.exit(1);
1056
1403
  }
1057
1404
  // Resolve plugins and deduplication maps
1058
1405
  s.message('Installing components');
1059
1406
  const rootDir = getPackageRoot();
1060
- let pluginsToInstall = selectedPlugins.length > 0
1061
- ? DEVFLOW_PLUGINS.filter(p => selectedPlugins.includes(p.name))
1062
- : DEVFLOW_PLUGINS.filter(p => !p.optional);
1063
- const coreSkillsPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-core-skills');
1064
- if (pluginsToInstall.length > 0 && coreSkillsPlugin && !pluginsToInstall.includes(coreSkillsPlugin)) {
1065
- pluginsToInstall = [coreSkillsPlugin, ...pluginsToInstall];
1066
- }
1067
- const ambientPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-ambient');
1068
- if (ambientEnabled && ambientPlugin && !pluginsToInstall.includes(ambientPlugin)) {
1069
- pluginsToInstall.push(ambientPlugin);
1070
- }
1071
- // Skills: install ALL from ALL plugins (skills are tiny markdown files;
1072
- // commands need skills from other plugins to function)
1073
- const skillsMap = buildFullSkillsMap();
1407
+ const pluginsToInstall = resolvePluginsToInstall(selectedPlugins, ambientEnabled, DEVFLOW_PLUGINS);
1408
+ // The EFFECTIVE selection — what the manifest will record, resolved here
1409
+ // rather than at manifest-write time because the skills install set is
1410
+ // derived from it. On a full install it is `pluginsToInstall`; on a partial
1411
+ // install (`--plugin=X`) it merges the prior manifest's plugins with X, so a
1412
+ // previously-installed plugin's skills survive an add-one run (AC-22).
1413
+ const installedPluginNames = pluginsToInstall.map(pl => pl.name);
1414
+ const effectivePluginNames = resolvePluginList(installedPluginNames, existingManifest, !!options.plugin);
1415
+ const effectivePlugins = DEVFLOW_PLUGINS.filter(pl => effectivePluginNames.includes(pl.name));
1416
+ // Skills: the effective selection's closure — every plugin's own skills plus
1417
+ // the ones it requires. Scoped like rules, agents and commands already are.
1418
+ const skillsMap = buildScopedSkillsMap(effectivePlugins);
1074
1419
  // Agents: install only from selected plugins
1075
1420
  const { agentsMap } = buildAssetMaps(pluginsToInstall);
1076
1421
  // Rules: install only from selected plugins (plugin-scoped, not universal)
@@ -1079,12 +1424,11 @@ export const initCommand = new Command('init')
1079
1424
  // Migrations clean up ~/.devflow runtime data and never touch the installer's copy
1080
1425
  // targets, so their position relative to installViaFileCopy carries no dependency.
1081
1426
  // Migrations are always-run-unapplied: helpers short-circuit when the target data is
1082
- // absent, so fresh installs are safe no-ops. State lives at the home-dir ~/.devflow
1083
- // location regardless of install scope (D30).
1427
+ // absent, so fresh installs are safe no-ops. State lives at the machine root
1428
+ // ~/.devflow (D30).
1084
1429
  {
1085
1430
  const { runMigrations } = await import('../../core/migrations.js');
1086
- const userDevflowDir = path.join(os.homedir(), '.devflow');
1087
- await runMigrationsWithFallback(discoveredProjects, gitRoot, userDevflowDir, { warn: p.log.warn, info: p.log.info, success: p.log.success }, verbose, runMigrations);
1431
+ await runMigrationsWithFallback(discoveredProjects, gitRoot, devflowDir, { warn: p.log.warn, info: p.log.info, success: p.log.success }, verbose, runMigrations);
1088
1432
  }
1089
1433
  // devflow-compliance was a selectable plugin in earlier releases; it is now a built-in
1090
1434
  // feature (devflow compliance --enable/--disable). If the prior manifest still lists it
@@ -1107,9 +1451,11 @@ export const initCommand = new Command('init')
1107
1451
  catch { /* absent — no legacy artifact */ }
1108
1452
  // Install via file copy
1109
1453
  let installReport;
1454
+ const installWarnings = [];
1110
1455
  try {
1111
1456
  installReport = await installViaFileCopy({
1112
1457
  plugins: pluginsToInstall,
1458
+ effectivePlugins,
1113
1459
  claudeDir,
1114
1460
  devflowDir,
1115
1461
  skillsMap,
@@ -1117,6 +1463,10 @@ export const initCommand = new Command('init')
1117
1463
  rulesMap,
1118
1464
  isPartialInstall: !!options.plugin,
1119
1465
  spinner: s,
1466
+ // Non-fatal install notices with no other channel (skipped symlinks in the
1467
+ // generated reference tree, mode-normalisation failures) reach the user rather
1468
+ // than the void. Collected now, emitted after the spinner stops.
1469
+ warn: (msg) => { installWarnings.push(msg); },
1120
1470
  });
1121
1471
  }
1122
1472
  catch (error) {
@@ -1136,13 +1486,13 @@ export const initCommand = new Command('init')
1136
1486
  manifest: { features: { compliance: { enabled: complianceEnabled, frameworks: complianceFrameworks }, rules: rulesEnabled } },
1137
1487
  warn: (msg) => p.log.warn(msg),
1138
1488
  });
1139
- // I41: emit legacy-upgrade notice when compliance is disabled AND pre-existing artifacts
1140
- // were found. After I09, the skill dir survives the orphan sweep (knownNames now unions
1141
- // FEATURE_OWNED_SKILLS), so convergeResult.removedPreexisting correctly fires for the
1142
- // skill path. hadComplianceRule covers the rule path (wiped by installViaFileCopy before
1143
- // converge probes on full installs).
1489
+ // I41: emit legacy-upgrade notice when compliance is disabled AND a pre-existing rule
1490
+ // was found. The skill is no signal — converge installs it on every machine
1491
+ // (D-COMPLIANCE-INSTALL-ALWAYS) — so removedPreexisting reports the rule alone, on a
1492
+ // partial install; hadComplianceRule covers full installs, where installViaFileCopy
1493
+ // wipes the rules dir before converge probes it.
1144
1494
  if (!complianceEnabled && (convergeResult.removedPreexisting || hadComplianceRule)) {
1145
- p.log.info('Compliance artifacts removed — if you previously had devflow-compliance installed, ' +
1495
+ p.log.info('Compliance rule removed — if you previously had devflow-compliance installed, ' +
1146
1496
  'run `devflow compliance --enable` to re-enable with your framework selection.');
1147
1497
  }
1148
1498
  }
@@ -1395,23 +1745,23 @@ export const initCommand = new Command('init')
1395
1745
  try {
1396
1746
  let content = await fs.readFile(settingsPath, 'utf-8');
1397
1747
  const original = content;
1398
- // Ambient hook — always remove-then-add to upgrade from legacy ambient-prompt → preamble
1399
- const cleanedForAmbient = await removeAmbientHook(content);
1400
- content = ambientEnabled ? await addAmbientHook(cleanedForAmbient, devflowDir) : cleanedForAmbient;
1748
+ // Ambient hooks — remove-then-add, upgrading a legacy ambient-prompt hook to preamble
1749
+ content = await convergeAmbientHooks(content, ambientEnabled, devflowDir);
1401
1750
  // Capture hooks — always-on (like the context hook below), remove-then-add for
1402
1751
  // upgrade safety. Queue-append only (capture-prompt/capture-turn/capture-question);
1403
- // each script gates its own per-queue write internally via feature config, so there
1404
- // is no CLI-level enable/disable toggle here. MUST run before addMemoryHooks below
1405
- // so capture-turn lands before memory-worker in the Stop array (AC-C2 ordering:
1406
- // append-before-spawn).
1752
+ // each script gates its own per-queue write on the machine-wide switch, so there
1753
+ // is no CLI-level enable/disable toggle here. Runs before convergeMemoryHooks below
1754
+ // so capture-turn lands before memory-worker in the Stop array, matching what
1755
+ // `devflow memory --enable` produces (AC-C2). The Stop hooks still run in
1756
+ // parallel; the memory worker tolerates a not-yet-appended turn.
1407
1757
  const cleanedForCapture = removeCaptureHooks(content);
1408
1758
  content = addCaptureHooks(cleanedForCapture, devflowDir);
1409
- // Memory hooks — always remove-then-add to upgrade hook format (e.g., .sh → run-hook).
1410
- // Three hooks: Stop (memory-worker), SessionStart (session-start-memory), PreCompact.
1411
- // Learning agent (spawned via session-start-context directive) handles decision/pitfall
1412
- // detection. Knowledge is handled in-command via write-through (knowledge_writeback MDS partial).
1413
- const cleaned = removeMemoryHooks(content);
1414
- content = memoryEnabled ? addMemoryHooks(cleaned, devflowDir) : cleaned;
1759
+ // Memory hooks — Stop (memory-worker), SessionStart (session-start-memory),
1760
+ // PreCompact — through the same transform `devflow memory --enable/--disable`
1761
+ // uses (D-FEATURES-NARROW-ONLY). Learning agent (spawned via
1762
+ // session-start-context directive) handles decision/pitfall detection.
1763
+ // Knowledge is handled in-command via write-through (knowledge_writeback MDS partial).
1764
+ content = convergeMemoryHooks(content, memoryEnabled, devflowDir);
1415
1765
  // HUD statusLine
1416
1766
  content = hudEnabled
1417
1767
  ? addHudStatusLine(content, devflowDir)
@@ -1465,7 +1815,7 @@ export const initCommand = new Command('init')
1465
1815
  if (proxyEnabled)
1466
1816
  content = applyProxyEnv(content, effectivePort);
1467
1817
  if (content !== original) {
1468
- await fs.writeFile(settingsPath, content, 'utf-8');
1818
+ await writeSettingsFileAtomic(settingsPath, content);
1469
1819
  if (verbose) {
1470
1820
  if (ambientEnabled)
1471
1821
  p.log.success('Ambient mode hook installed');
@@ -1480,39 +1830,33 @@ export const initCommand = new Command('init')
1480
1830
  p.log.warn(`Could not configure settings.json: ${err instanceof Error ? err.message : err}. ` +
1481
1831
  'Manifest records intended state; run devflow init again to retry.');
1482
1832
  }
1483
- // Write .devflow/config.json to manage per-feature enable/disable at runtime.
1484
- // Uses writeConfig (full atomic write) rather than three updateFeature calls because
1485
- // init always sets all three features at once and is never concurrent with toggle
1486
- // commands — it is a one-time setup action. See D1 in feature-config.ts for the
1487
- // concurrency assumption shared by both write strategies.
1833
+ // Write .devflow/config.json — facts about this repo. The machine switches for
1834
+ // memory/learning/knowledge are the manifest's; a hand-written `features`
1835
+ // object here only narrows them and is carried, never written
1836
+ // (D-FEATURES-NARROW-ONLY; the managed write drops the retired top-level
1837
+ // per-repo keys). A managed
1838
+ // read-modify-write, not a whole-file write: init owns only reviewPublication,
1839
+ // and every other key in the file — the hand-written per-repo `tracker`
1840
+ // override first among them — is carried from disk, under --reset too
1841
+ // (D-CONFIG-PRESERVE-UNMANAGED in feature-config.ts, avoids PF-071).
1842
+ // A malformed or unreadable file is left untouched and named (D-CONFIG-NO-REPAIR).
1488
1843
  if (gitRoot) {
1489
- await writeConfig(gitRoot, {
1490
- memory: memoryEnabled,
1491
- learning: learningEnabled,
1492
- knowledge: knowledgeEnabled,
1844
+ const configWrite = await writeManagedConfig(gitRoot, {
1493
1845
  // reviewPublication has no prompt, so it is carried over from the
1494
1846
  // reset-gated snapshot rather than re-read from disk: seedConfig is null
1495
1847
  // under --reset, which is what collapses the field back to 'auto' with
1496
1848
  // every other feature (PF-015 — read the post-gate binding, not the file).
1497
- reviewPublication: seedConfig?.reviewPublication ?? 'auto',
1849
+ reviewPublication: seedConfig?.reviewPublication ?? DEFAULT_CONFIG.reviewPublication,
1498
1850
  });
1499
- // Drain orphaned queue files when memory is disabled so stale turns
1500
- // don't process on a future re-enable. Mirrors memory.ts --disable drain.
1501
- if (!memoryEnabled) {
1502
- await Promise.all([
1503
- fs.unlink(getPendingTurnsPath(gitRoot)).catch((e) => { if (e.code !== 'ENOENT')
1504
- throw e; }),
1505
- fs.unlink(getPendingTurnsProcessingPath(gitRoot)).catch((e) => { if (e.code !== 'ENOENT')
1506
- throw e; }),
1507
- ]);
1508
- }
1851
+ if (!configWrite.ok)
1852
+ p.log.warn(formatManagedConfigWriteError(configWrite.error));
1509
1853
  }
1510
1854
  // Configure HUD
1511
1855
  const existingHud = loadHudConfig();
1512
1856
  saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail });
1513
1857
  // File extras
1514
1858
  if (claudeignoreEnabled) {
1515
- if (scope === 'user' && discoveredProjects.length > 0) {
1859
+ if (discoveredProjects.length > 0) {
1516
1860
  const results = await Promise.all(discoveredProjects.map(root => installClaudeignore(root, rootDir, verbose)));
1517
1861
  const created = results.filter(Boolean).length;
1518
1862
  if (created > 0) {
@@ -1527,18 +1871,12 @@ export const initCommand = new Command('init')
1527
1871
  }
1528
1872
  }
1529
1873
  // Deterministically ensure .devflow/ is gitignored at the repo root — independent
1530
- // of install scope and every feature toggle. The always-on ensure-root-gitignore
1874
+ // of every feature toggle. The always-on ensure-root-gitignore
1531
1875
  // hook covers projects that never re-run init; this covers the init-time path so a
1532
1876
  // fresh install never tracks .devflow/. Decoupled from memory (avoids PF-014).
1533
1877
  if (gitRoot) {
1534
1878
  await ensureDevflowGitignore(gitRoot, verbose);
1535
1879
  }
1536
- if (scope === 'local' && gitRoot) {
1537
- await updateGitignore(gitRoot, verbose);
1538
- }
1539
- if (scope === 'local') {
1540
- await createDocsStructure(verbose);
1541
- }
1542
1880
  // Safe-delete execution (decision was captured during prompt phase)
1543
1881
  if (safeDeleteAction === 'install' && safeDeleteBlock && profilePath) {
1544
1882
  await installToProfile(profilePath, safeDeleteBlock);
@@ -1626,11 +1964,26 @@ export const initCommand = new Command('init')
1626
1964
  }
1627
1965
  }
1628
1966
  else if (securityMode === 'none') {
1629
- // None: strip Devflow deny entries from user settings.
1630
- // Uses the canonical helper (atomic temp+rename; ENOENT-safe; only-write-if-changed).
1967
+ // None: strip Devflow deny entries from EVERY location, as
1968
+ // `devflow security --disable` does. User settings first, via the
1969
+ // canonical helper (atomic temp+rename; ENOENT-safe; only-write-if-changed).
1631
1970
  const stripResult = await stripUserSecurityDenyList(userSettingsPath);
1632
1971
  if (stripResult && verbose)
1633
1972
  p.log.info(`Security deny list removed (${stripResult.removed.length} entries stripped)`);
1973
+ // Then managed settings, through the one removal security.ts also uses.
1974
+ // Before this, `none` left the managed file in place: the deny list kept
1975
+ // applying — at the highest precedence — while the manifest said `none`.
1976
+ // A permission failure is reported, never thrown (the install already
1977
+ // succeeded; the manifest records the choice and a re-run can retry).
1978
+ if (managedDenyDetected) {
1979
+ s.stop('Removing managed security settings (may prompt for sudo password)...');
1980
+ const managedMsg = describeManagedDenyRemoval(await removeManagedDenyList(rootDir, verbose));
1981
+ if (managedMsg.level === 'warn')
1982
+ p.log.warn(managedMsg.text);
1983
+ else if (verbose)
1984
+ p.log.info(managedMsg.text);
1985
+ s.start('Finalizing installation...');
1986
+ }
1634
1987
  }
1635
1988
  else {
1636
1989
  // Exhaustive guard — if TypeScript reaches here, a new SecurityMode variant was added
@@ -1683,12 +2036,21 @@ export const initCommand = new Command('init')
1683
2036
  // failed removal leaves a retired asset live. Both must surface.
1684
2037
  // After I09, the installer's knownNames set unions FEATURE_OWNED_SKILLS, so
1685
2038
  // devflow:compliance is never swept here — no suppression predicate is needed.
1686
- for (const line of formatSweepSummary(installReport)) {
1687
- if (line.level === 'warn')
1688
- p.log.warn(line.message);
1689
- else
1690
- p.log.info(line.message);
1691
- }
2039
+ logSummaryLines(formatSweepSummary(installReport));
2040
+ // Reference-overlay reporting: the overlay rewrites files inside an installed skill
2041
+ // the user may have shadowed, and reports any unit it had to leave alone (PF-015).
2042
+ logSummaryLines(formatOverlaySummary(installReport));
2043
+ // Skill-scoping reporting: a deselected skill is deleted and a dormant shadow
2044
+ // is inert, and neither is distinguishable from "never installed" on disk.
2045
+ //
2046
+ // L2: "the plugin list is unchanged" means a prior manifest EXISTS and its
2047
+ // plugin set equals this run's. A first install had nothing to remove, so
2048
+ // there is no upgrade to explain — the removal notice would be addressed to
2049
+ // a user who never had the skills.
2050
+ const pluginListUnchanged = isPluginListUnchanged(existingManifest?.plugins ?? null, effectivePluginNames);
2051
+ logSummaryLines(formatSkillScopeSummary(installReport, pluginListUnchanged));
2052
+ for (const warning of installWarnings)
2053
+ p.log.warn(warning);
1692
2054
  const installedSet = new Set(pluginsToInstall.flatMap(p => p.commands).filter(c => c.length > 0));
1693
2055
  const orderedCommands = WORKFLOW_ORDER.filter(cmd => installedSet.has(cmd));
1694
2056
  if (orderedCommands.length > 0) {
@@ -1732,7 +2094,6 @@ export const initCommand = new Command('init')
1732
2094
  .map(plugin => `${color.yellow(plugin.name.padEnd(24))}${color.dim(plugin.description)}`)
1733
2095
  .join('\n');
1734
2096
  p.note(pluginsList, 'Installed plugins');
1735
- p.log.info(`Scope: ${scope}`);
1736
2097
  p.log.info(`Claude dir: ${claudeDir}`);
1737
2098
  p.log.info(`Devflow dir: ${devflowDir}`);
1738
2099
  const totalSkillDeclarations = pluginsToInstall.reduce((sum, p) => sum + p.skills.length, 0);
@@ -1741,12 +2102,14 @@ export const initCommand = new Command('init')
1741
2102
  p.log.info(`Deduplication: ${agentsMap.size} unique agents (from ${totalAgentDeclarations} declarations)`);
1742
2103
  }
1743
2104
  // Write installation manifest for upgrade tracking (non-fatal — install already succeeded)
1744
- const installedPluginNames = pluginsToInstall.map(pl => pl.name);
1745
2105
  const now = new Date().toISOString();
1746
2106
  const manifestData = {
1747
2107
  version,
1748
- plugins: resolvePluginList(installedPluginNames, existingManifest, !!options.plugin),
1749
- scope,
2108
+ // Resolved above, before the install, because the skills install set is
2109
+ // derived from it — one binding, so the manifest can never record a
2110
+ // selection other than the one the assets were installed for.
2111
+ plugins: effectivePluginNames,
2112
+ scope: 'user',
1750
2113
  // Snapshot of known plugin names at this install — used by resolveSeedPlugins on next init
1751
2114
  // to detect new non-optional plugins and auto-adopt them.
1752
2115
  knownPlugins: DEVFLOW_PLUGINS.map(p => p.name),
@@ -1767,16 +2130,62 @@ export const initCommand = new Command('init')
1767
2130
  // and Advanced wizard selection. convergeComplianceArtifacts was called above.
1768
2131
  // normalizeFrameworks: dedup + filter unknowns before persisting.
1769
2132
  compliance: { enabled: complianceEnabled, frameworks: normalizeFrameworks(complianceFrameworks) },
2133
+ // Resolved tracker selection. Already a validated TrackerProvider — it came
2134
+ // through parseTrackerId (CLI), the typed wizard select, or the seed, which
2135
+ // itself came through normalizeTrackerFeature on read.
2136
+ tracker: { provider: trackerProvider },
1770
2137
  },
1771
2138
  installedAt: existingManifest?.installedAt ?? now,
1772
2139
  updatedAt: now,
1773
2140
  };
1774
- try {
1775
- await writeManifest(devflowDir, manifestData);
1776
- }
1777
- catch (error) {
1778
- p.log.warn(`Failed to write installation manifest (install succeeded): ${error instanceof Error ? error.message : error}`);
1779
- }
2141
+ // ── Manifest write + tracker selection lifecycle (the ONE call site) ──────
2142
+ // persistManifestThenConvergeTracker owns the ordering invariant: the
2143
+ // tracker file-lifecycle owners in src/core/tracker.ts converge only against
2144
+ // a provider the manifest actually persisted (D-TRACKER-CONVERGE, PF-015).
2145
+ const trackerLifecycle = await persistManifestThenConvergeTracker({
2146
+ devflowDir,
2147
+ claudeDir,
2148
+ manifestData,
2149
+ io: buildTrackerLifecycleIO(),
2150
+ });
2151
+ for (const msg of trackerLifecycle.messages) {
2152
+ if (msg.level === 'warn')
2153
+ p.log.warn(msg.text);
2154
+ else
2155
+ p.log.info(msg.text);
2156
+ }
2157
+ // Only now that the machine-wide switch is on disk (D-INIT-DRAIN-AFTER-SWITCH).
2158
+ await drainDisabledFeatureQueues({
2159
+ gitRoot,
2160
+ ledgerRoot: learningEnabled || gitRoot === null ? null : await getLedgerRoot(),
2161
+ memoryEnabled,
2162
+ learningEnabled,
2163
+ manifestWritten: trackerLifecycle.manifestWritten,
2164
+ });
2165
+ // The hooks' per-directory log folders, capped (D-LOG-DIR-CAP): one pass
2166
+ // clears every folder it scans beyond the cap; only a backlog beyond the
2167
+ // scan bound (MAX_LOG_DIRS_SCANNED, 100,000) waits for the next init.
2168
+ const logPrune = await pruneHookLogDirs(path.join(devflowDir, 'logs'));
2169
+ if (!logPrune.ok) {
2170
+ if (verbose)
2171
+ p.log.warn(`Could not prune hook log folders: ${logPrune.error}`);
2172
+ }
2173
+ else if (logPrune.value.removed > 0) {
2174
+ p.log.info(formatLogPruneLine(logPrune.value));
2175
+ }
2176
+ // Name the active provider and what the selection moved. The reference
2177
+ // counts come from the install report rather than being recomputed: the
2178
+ // overlay is what actually installed and pruned them, so a second count
2179
+ // here could only ever disagree with it.
2180
+ const trackerLines = formatTrackerAssetSummary({
2181
+ provider: trackerProvider,
2182
+ previous: existingManifest?.features.tracker.provider,
2183
+ isDefault: trackerProvider === DEFAULT_TRACKER_PROVIDER,
2184
+ installedRefs: installReport.overlaidRefs.length,
2185
+ removedRefs: installReport.sweptOrphans.filter(o => o.kind === 'reference').length,
2186
+ agent: trackerLifecycle.agent,
2187
+ });
2188
+ logSummaryLines(trackerLines);
1780
2189
  // External model routing status line (Advanced path / explicit --proxy flag only)
1781
2190
  if (proxyEnabled) {
1782
2191
  p.log.info(`External model routing: ${color.green('enabled')} — takes effect in new Claude Code sessions`);