devflow-kit 2.5.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 (158) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +44 -19
  3. package/dist/agents/git.md +13 -15
  4. package/dist/cli/commands/ambient.js +160 -145
  5. package/dist/cli/commands/capture.js +29 -55
  6. package/dist/cli/commands/compliance.js +32 -61
  7. package/dist/cli/commands/context.js +17 -32
  8. package/dist/cli/commands/debug.js +65 -26
  9. package/dist/cli/commands/flags.js +3 -3
  10. package/dist/cli/commands/hud.js +34 -10
  11. package/dist/cli/commands/init-seed.js +40 -4
  12. package/dist/cli/commands/init.js +249 -271
  13. package/dist/cli/commands/install-report.js +10 -15
  14. package/dist/cli/commands/knowledge/index.js +1 -1
  15. package/dist/cli/commands/knowledge/toggle.js +11 -3
  16. package/dist/cli/commands/learning.js +52 -37
  17. package/dist/cli/commands/legacy-hooks.js +11 -14
  18. package/dist/cli/commands/memory.js +67 -78
  19. package/dist/cli/commands/proxy.js +23 -41
  20. package/dist/cli/commands/security.js +5 -13
  21. package/dist/cli/commands/skills.js +21 -3
  22. package/dist/cli/commands/tracker.js +100 -228
  23. package/dist/cli/commands/uninstall.js +343 -138
  24. package/dist/commands/bug-analysis.md +38 -12
  25. package/dist/commands/code-review.md +70 -21
  26. package/dist/commands/debug.md +37 -7
  27. package/dist/commands/dynamic-build.md +66 -17
  28. package/dist/commands/dynamic-plan.md +19 -8
  29. package/dist/commands/dynamic-profile.md +24 -10
  30. package/dist/commands/dynamic-tickets.md +22 -11
  31. package/dist/commands/explore.md +37 -7
  32. package/dist/commands/implement.md +96 -32
  33. package/dist/commands/plan.md +62 -19
  34. package/dist/commands/release.md +2 -2
  35. package/dist/commands/research.md +34 -8
  36. package/dist/commands/resolve.md +65 -17
  37. package/dist/commands/self-review.md +45 -9
  38. package/dist/core/compliance-compose.js +27 -27
  39. package/dist/core/evidence-policy.js +240 -24
  40. package/dist/core/feature-config.js +94 -25
  41. package/dist/core/feature-switch.js +1 -1
  42. package/dist/core/flags.js +30 -2
  43. package/dist/core/fs-atomic.js +27 -0
  44. package/dist/core/hook-log-dirs.js +104 -0
  45. package/dist/core/learning-tuning-config.js +5 -3
  46. package/dist/core/ledger-root.js +102 -0
  47. package/dist/core/manifest.js +6 -4
  48. package/dist/core/mds-variants.js +34 -97
  49. package/dist/core/migrations.js +49 -23
  50. package/dist/core/plugins.js +5 -4
  51. package/dist/core/project-paths.js +0 -17
  52. package/dist/core/same-location.js +25 -0
  53. package/dist/core/tracker.js +226 -139
  54. package/dist/hud/components/config-counts.js +15 -4
  55. package/dist/hud/components/learning-counts.js +14 -0
  56. package/dist/hud/config.js +2 -1
  57. package/dist/hud/cost-history.js +2 -4
  58. package/dist/hud/git.js +52 -7
  59. package/dist/hud/index.js +7 -9
  60. package/dist/skills/git/references/pr/check-merge-readiness.md +1 -1
  61. package/dist/skills/git/references/pr/ensure-pr-ready.md +1 -1
  62. package/dist/skills/git/references/pr/update-pr-evidence.md +1 -1
  63. package/dist/skills/git/references/tracker/_mcp.md +1 -1
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +1 -1
  65. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +1 -1
  66. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +2 -2
  67. package/dist/skills/git/references/tracker/github/manage-debt.md +3 -3
  68. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +1 -1
  70. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +2 -2
  71. package/dist/skills/git/references/tracker/jira/manage-debt.md +1 -1
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +1 -1
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +1 -1
  74. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +1 -1
  76. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +2 -2
  77. package/dist/skills/git/references/tracker/linear/manage-debt.md +1 -1
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +1 -1
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +1 -1
  80. package/dist/targets/claude-code/claude-paths.js +59 -57
  81. package/dist/targets/claude-code/compliance-install.js +49 -65
  82. package/dist/targets/claude-code/hooks.js +108 -3
  83. package/dist/targets/claude-code/installer.js +30 -57
  84. package/dist/targets/claude-code/post-install.js +232 -139
  85. package/dist/targets/claude-code/tracker-install.js +38 -65
  86. package/package.json +5 -4
  87. package/src/assets/agents/code.md +4 -3
  88. package/src/assets/agents/design.md +1 -0
  89. package/src/assets/agents/git.mds +55 -57
  90. package/src/assets/agents/knowledge.md +2 -2
  91. package/src/assets/agents/review.md +3 -1
  92. package/src/assets/agents/tracker.md +37 -30
  93. package/src/assets/commands/_partials/_compliance.mds +19 -1
  94. package/src/assets/commands/_partials/_decisions.mds +15 -3
  95. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  96. package/src/assets/commands/_partials/_engine.mds +2 -2
  97. package/src/assets/commands/_partials/_evidence_policy.mds +3 -3
  98. package/src/assets/commands/_partials/_factory.mds +1 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  100. package/src/assets/commands/_partials/_plan_contract.mds +2 -2
  101. package/src/assets/commands/_partials/_preamble.mds +1 -1
  102. package/src/assets/commands/_partials/_publication.mds +6 -2
  103. package/src/assets/commands/_partials/_settings.mds +28 -0
  104. package/src/assets/commands/_partials/_ticket_template.mds +3 -3
  105. package/src/assets/commands/_partials/_tracker.mds +4 -4
  106. package/src/assets/commands/_partials/_wave.mds +4 -4
  107. package/src/assets/commands/bug-analysis.mds +19 -17
  108. package/src/assets/commands/code-review.mds +39 -33
  109. package/src/assets/commands/debug.mds +4 -5
  110. package/src/assets/commands/dynamic-build.mds +75 -53
  111. package/src/assets/commands/dynamic-plan.mds +20 -15
  112. package/src/assets/commands/dynamic-profile.mds +24 -11
  113. package/src/assets/commands/dynamic-tickets.mds +25 -20
  114. package/src/assets/commands/explore.mds +4 -5
  115. package/src/assets/commands/implement.mds +58 -45
  116. package/src/assets/commands/plan.mds +34 -29
  117. package/src/assets/commands/release.md +2 -2
  118. package/src/assets/commands/research.mds +11 -9
  119. package/src/assets/commands/resolve.mds +41 -39
  120. package/src/assets/commands/self-review.mds +24 -25
  121. package/src/assets/mds/git/_pr.mds +61 -61
  122. package/src/assets/mds/git/_references.mds +19 -19
  123. package/src/assets/mds/tracker/_common.mds +8 -8
  124. package/src/assets/mds/tracker/_github.mds +71 -71
  125. package/src/assets/mds/tracker/_jira.mds +74 -74
  126. package/src/assets/mds/tracker/_linear.mds +75 -75
  127. package/src/assets/mds/tracker/_mcp.mds +23 -17
  128. package/src/assets/scripts/hooks/background-memory-update +35 -19
  129. package/src/assets/scripts/hooks/capture-prompt +18 -12
  130. package/src/assets/scripts/hooks/capture-question +18 -12
  131. package/src/assets/scripts/hooks/capture-turn +27 -17
  132. package/src/assets/scripts/hooks/debug-trace +11 -6
  133. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  134. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  135. package/src/assets/scripts/hooks/ensure-root-gitignore +111 -36
  136. package/src/assets/scripts/hooks/git-marker +48 -0
  137. package/src/assets/scripts/hooks/json-helper.cjs +6 -1
  138. package/src/assets/scripts/hooks/lib/project-paths.cjs +0 -19
  139. package/src/assets/scripts/hooks/log-paths +80 -0
  140. package/src/assets/scripts/hooks/memory-worker +17 -15
  141. package/src/assets/scripts/hooks/pre-compact-memory +41 -16
  142. package/src/assets/scripts/hooks/queue-append +104 -30
  143. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  144. package/src/assets/scripts/hooks/session-start-context +289 -122
  145. package/src/assets/scripts/hooks/session-start-memory +35 -16
  146. package/src/assets/scripts/lib/project-config.cjs +633 -0
  147. package/src/assets/scripts/resolve-evidence-policy.cjs +300 -220
  148. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  149. package/src/assets/scripts/verify-evidence.cjs +1 -1
  150. package/src/assets/skills/compliance/SKILL.md +2 -2
  151. package/src/assets/skills/docs-framework/SKILL.md +6 -7
  152. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  153. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  154. package/src/assets/skills/git/references/github-api.md +9 -9
  155. package/src/assets/skills/git/references/patterns.md +1 -1
  156. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  157. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  158. package/src/targets/claude-code/templates/managed-settings.json +25 -9
@@ -1,20 +1,23 @@
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
13
  import { formatOverlaySummary, formatSkillScopeSummary, formatTrackerAssetSummary, isPluginListUnchanged } from './install-report.js';
11
14
  import { convergeTrackerArtifacts } from '../../targets/claude-code/tracker-install.js';
12
- import { installSettings, installManagedSettings, installClaudeignore, discoverProjectGitRoots, updateGitignore, ensureDevflowGitignore, createDocsStructure, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
15
+ import { installSettings, installManagedSettings, installClaudeignore, discoverProjectGitRoots, ensureDevflowGitignore, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
13
16
  import { DEVFLOW_PLUGINS, LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES, buildAssetMaps, buildScopedSkillsMap, buildRulesMap, partitionSelectablePlugins, WORKFLOW_ORDER, parsePluginSelection, resolveFeatureRedirect } from '../../core/plugins.js';
14
17
  import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
15
18
  import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../../core/safe-delete.js';
16
19
  import { generateSafeDeleteBlock, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../../core/safe-delete-install.js';
17
- import { addAmbientHook, removeAmbientHook } from './ambient.js';
20
+ import { convergeAmbientHooks } from './ambient.js';
18
21
  import { convergeMemoryHooks, drainMemoryQueue } from './memory.js';
19
22
  import { addCaptureHooks, removeCaptureHooks } from './capture.js';
20
23
  import { removeDreamHook } from './legacy-hooks.js';
@@ -28,19 +31,20 @@ import { loadConfig as loadHudConfig, saveConfig as saveHudConfig } from '../../
28
31
  import { readManifest, writeManifest, resolvePluginList, detectUpgrade } from '../../core/manifest.js';
29
32
  import { convergeFlagsIntoSettings, countActiveFlags, readViewMode } from '../../core/flags.js';
30
33
  import { addContextHook, removeContextHook, hasContextHook } from './context.js';
34
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
31
35
  import { writeManagedConfig, readConfigIfPresent, DEFAULT_CONFIG } from '../../core/feature-config.js';
32
36
  import { drainLearningQueue } from '../../core/learning-queue-cleanup.js';
33
37
  import { removeManagedDenyList, describeManagedDenyRemoval } from './security.js';
34
- import { resolveInitSeed, applyCliToggles, resolveResetGatedInputs } from './init-seed.js';
38
+ import { resolveInitSeed, applyCliToggles, resolveResetGatedInputs, resolvePluginsToInstall } from './init-seed.js';
35
39
  import { parseFrameworkList, normalizeFrameworks } from '../../core/compliance.js';
36
40
  import { formatComplianceSummary, shouldRunComplianceStep, runComplianceStep, buildClackCompliancePrompts, } from './compliance-prompts.js';
37
- import { applyTrackerSentinel, parseTrackerId, rearmTrackerInference, renameStaleTrackerConventions, DEFAULT_TRACKER_PROVIDER, } from '../../core/tracker.js';
41
+ import { applyTrackerSentinel, parseTrackerId, rearmTrackerInference, DEFAULT_TRACKER_PROVIDER, } from '../../core/tracker.js';
38
42
  import { formatTrackerSummary, shouldRunTrackerStep, runTrackerStep, buildClackTrackerPrompts, } from './tracker-prompts.js';
39
43
  import { shouldRunAttributionStep, runAttributionStep, buildClackAttributionPrompts, applyAttributionAnswer, attributionSeedFrom, } from './attribution-prompts.js';
40
44
  import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
41
45
  import * as os from 'os';
42
46
  // Re-export pure functions for tests (canonical source is post-install.ts)
43
- export { substituteSettingsTemplate, computeGitignoreAppend, mergeDenyList, discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
47
+ export { substituteSettingsTemplate, mergeDenyList, discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
44
48
  export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js';
45
49
  export { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js';
46
50
  export { addCaptureHooks, removeCaptureHooks, hasCaptureHooks } from './capture.js';
@@ -240,8 +244,7 @@ export function trackerOverrideMessage(provider) {
240
244
  export function buildTrackerLifecycleIO() {
241
245
  return {
242
246
  writeManifest,
243
- renameStaleConventions: renameStaleTrackerConventions,
244
- convergeArtifacts: (claudeDir, provider, warn) => convergeTrackerArtifacts({ claudeDir, provider, warn }),
247
+ convergeArtifacts: (claudeDir, warn) => convergeTrackerArtifacts({ claudeDir, warn }),
245
248
  rearmInference: rearmTrackerInference,
246
249
  applySentinel: applyTrackerSentinel,
247
250
  };
@@ -250,33 +253,26 @@ export function buildTrackerLifecycleIO() {
250
253
  * Persist the installation manifest, then converge the tracker artifacts against
251
254
  * the provider that was actually persisted.
252
255
  *
253
- * D-TRACKER-CONVERGE: the manifest write and the three tracker file-lifecycle
254
- * owners are ONE unit because their relative order is the invariant, not an
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
255
258
  * implementation detail (PF-015). The manifest write is explicitly failable —
256
- * init must not abort on it — so converging the sentinel, the attempt counter or
257
- * the conventions file ahead of it leaves the artifacts disagreeing in both
258
- * directions: github→jira writes a sentinel for a provider the manifest never
259
- * records (a per-session fork cost forever), and jira→github removes the
260
- * sentinel, renames tracker.md to .bak and leaves the manifest on jira (silent
261
- * permanent degradation with no re-trigger). Writing first and gating the three
262
- * owners on `manifestWritten` makes the artifacts converge all-or-none, and puts
263
- * this call site in the same order as the sibling `devflow tracker --set`
264
- * (src/cli/commands/tracker.ts): rename → persist → rearm → sentinel.
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.
265
266
  *
266
267
  * The provider is read from `manifestData.features.tracker.provider` rather than
267
- * taken as a separate argument, so there is exactly one binding and the artifacts
268
- * cannot converge on a value other than the one on disk.
269
- *
270
- * `previousProvider` is the caller's REAL prior manifest value, never the
271
- * --reset-gated seed: under --reset the resolved provider collapses to github
272
- * while the prior provider is still jira/linear, and that IS a transition the
273
- * stale-conventions rename has to fire on.
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.
274
270
  *
275
271
  * Every step reports rather than aborts (PF-009's isolation posture): a
276
272
  * feature-state change must never fail `devflow init`.
277
273
  */
278
274
  export async function persistManifestThenConvergeTracker(opts) {
279
- const { devflowDir, claudeDir, manifestData, previousProvider, io } = opts;
275
+ const { devflowDir, claudeDir, manifestData, io } = opts;
280
276
  const provider = manifestData.features.tracker.provider;
281
277
  const messages = [];
282
278
  // The gate. Non-fatal for the install (which has already succeeded) but
@@ -293,77 +289,35 @@ export async function persistManifestThenConvergeTracker(opts) {
293
289
  });
294
290
  messages.push({
295
291
  level: 'warn',
296
- text: `Tracker selection (${provider}) was not persisted — the sentinel, attempt counter and ` +
297
- `conventions file are unchanged. Re-run devflow init, or devflow tracker --set ${provider}.`,
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}.`,
298
294
  });
299
295
  return { manifestWritten: false, converged: false, agent: 'unchanged', messages };
300
296
  }
301
- // Move a now-stale conventions file aside (the writer arm of the provider change).
302
- //
303
- // D-TRACKER-PARALLEL: the rename stays strictly ahead of the other two. It is
304
- // the only step that reads the PREVIOUS provider and the only one that reports
305
- // a transition, so keeping it first fixes the message order (the transition
306
- // notice always precedes any owner warning) and keeps the sequence readable as
307
- // "settle the old provider, then converge the new one". The two that follow
308
- // touch disjoint files — the attempt counter and the presence sentinel —
309
- // depend on nothing the other writes, and both report through TrackerResult
310
- // instead of throwing (PF-014), so they run concurrently and their warnings
311
- // are pushed in a fixed order regardless of which settles first.
312
- const transition = await io.renameStaleConventions(devflowDir, previousProvider, provider);
313
- if (transition.kind === 'renamed') {
314
- messages.push({
315
- level: 'info',
316
- text: `Tracker provider changed — previous ${transition.previous} conventions moved to ` +
317
- `${color.dim(transition.to)}`,
318
- });
319
- }
320
- else if (transition.kind === 'failed') {
321
- messages.push({ level: 'warn', text: transition.error });
322
- }
323
- // The fourth owner — the Tracker agent file. SEQUENTIAL, and strictly before
324
- // the pair below, because the sentinel's WRITE is gated on its outcome: a
325
- // sentinel that advertises jira while the agent it would spawn is missing is
326
- // the drifted state this whole ordering exists to prevent (design review H6 —
327
- // the parallel pair stays a parallel pair, it is not flattened to make room).
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.
328
299
  const agentWarnings = [];
329
- const artifacts = await io.convergeArtifacts(claudeDir, provider, (msg) => agentWarnings.push(msg));
300
+ const artifacts = await io.convergeArtifacts(claudeDir, (msg) => agentWarnings.push(msg));
330
301
  for (const text of agentWarnings)
331
302
  messages.push({ level: 'warn', text });
332
- // C2: the sentinel converges in BOTH directions, and only the WRITE is gated.
333
- // provider ≠ github → a write, when a spawnable agent is actually there.
334
- // provider = github → a removal. ALWAYS attempted, because leaving a stale
335
- // sentinel behind costs every future session a fork for a provider the
336
- // user has left, and a failed agent removal is not a reason to keep it.
337
- //
338
- // The write gate reads `agentPresent`, not `converged`. `converged` answers
339
- // "did THIS run copy it", and reading that alone is wrong in both directions:
340
- // a re-copy that fails over an already-installed agent would disable a provider
341
- // that still works, and merely SUPPRESSING the write leaves the PREVIOUS
342
- // provider's sentinel in place — so a jira → linear init whose agent copy
343
- // failed goes on advertising jira, which is the state the suppression exists to
344
- // prevent. Not-spawnable therefore REMOVES, through the one sentinel owner in
345
- // src/core/tracker.ts (D-TRACKER-OWNER), never an inline fs.rm here.
346
- const advertisable = provider === DEFAULT_TRACKER_PROVIDER || artifacts.agentPresent;
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.
347
308
  const [rearm, sentinel] = await Promise.all([
348
- // [DR-22] The documented re-arm path: devflow init resets the attempt counter
309
+ // [DR-22] The documented re-arm path: devflow init resets the attempt counters
349
310
  // so a previously-capped inference gets another five tries.
350
311
  io.rearmInference(devflowDir),
351
- // [DR-10] Converge the presence sentinel: written for jira/linear, removed for
352
- // github. This is what keeps the GitHub SessionStart path at one stat and zero forks.
353
- io.applySentinel(devflowDir, advertisable ? provider : DEFAULT_TRACKER_PROVIDER),
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),
354
316
  ]);
355
317
  if (!rearm.ok)
356
318
  messages.push({ level: 'warn', text: rearm.error });
357
319
  if (!sentinel.ok)
358
320
  messages.push({ level: 'warn', text: sentinel.error });
359
- if (!advertisable) {
360
- messages.push({
361
- level: 'warn',
362
- text: `Tracker sentinel removed — no ${provider} agent is installed, so nothing advertises a ` +
363
- `provider whose agent is missing and no session will try to spawn it. ` +
364
- `Re-run devflow init, or devflow tracker --set ${provider}.`,
365
- });
366
- }
367
321
  return {
368
322
  manifestWritten: true,
369
323
  converged: artifacts.converged && sentinel.ok,
@@ -385,34 +339,38 @@ export async function persistManifestThenConvergeTracker(opts) {
385
339
  * concurrent session, still reading the old "on", appended turns that then
386
340
  * survived the disable. When the manifest write failed the feature is still on
387
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.
388
346
  */
389
347
  export async function drainDisabledFeatureQueues(opts, io = { drainMemoryQueue, drainLearningQueue }) {
390
- if (!opts.manifestWritten || opts.gitRoot === null)
348
+ if (!opts.manifestWritten)
391
349
  return;
392
- if (!opts.memoryEnabled)
350
+ if (!opts.memoryEnabled && opts.gitRoot !== null)
393
351
  await io.drainMemoryQueue(opts.gitRoot);
394
- if (!opts.learningEnabled)
395
- await io.drainLearningQueue(opts.gitRoot);
352
+ if (!opts.learningEnabled && opts.ledgerRoot !== null)
353
+ await io.drainLearningQueue(opts.ledgerRoot);
396
354
  }
397
355
  /**
398
356
  * The manifest `devflow init --hud-only` writes. Pure — never mutates `existing`.
399
357
  *
400
358
  * D-HUD-ONLY-PRESERVE: over a prior install, --hud-only installs the HUD and
401
359
  * nothing else, so the manifest keeps every recorded value — plugins, version,
402
- * scope, installedAt and every feature — and only `features.hud` turns on. The
360
+ * installedAt and every feature — and only `features.hud` turns on. The
403
361
  * earlier shape rewrote the whole record as a HUD-only fresh install (ambient,
404
362
  * memory, learning, knowledge, rules and proxy all `false`, plugins `[]`) while
405
363
  * leaving those features' artifacts on disk: the record stopped describing the
406
364
  * machine, the next re-init seeded every feature off (ADR-014), and with
407
365
  * memory/learning/knowledge switched by the manifest alone
408
- * (D-FEATURES-MACHINE-WIDE) a HUD install would have really disabled them
366
+ * (D-FEATURES-NARROW-ONLY) a HUD install would have really disabled them
409
367
  * everywhere. `version` is kept too: --hud-only reinstalls no plugin, and a
410
368
  * bumped version would make the next init skip the upgrade it still owes.
411
369
  *
412
370
  * With no prior manifest the result is the fresh HUD-only record: every other
413
371
  * feature off, which is what is installed.
414
372
  */
415
- export function buildHudOnlyManifest(existing, version, scope, now) {
373
+ export function buildHudOnlyManifest(existing, version, now) {
416
374
  if (existing !== null) {
417
375
  return {
418
376
  ...existing,
@@ -423,7 +381,7 @@ export function buildHudOnlyManifest(existing, version, scope, now) {
423
381
  return {
424
382
  version,
425
383
  plugins: [],
426
- scope,
384
+ scope: 'user',
427
385
  features: {
428
386
  ambient: false, memory: false, hud: true, knowledge: false,
429
387
  learning: false, rules: false, flags: {}, proxy: false,
@@ -436,9 +394,84 @@ export function buildHudOnlyManifest(existing, version, scope, now) {
436
394
  updatedAt: now,
437
395
  };
438
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
+ }
439
472
  export const initCommand = new Command('init')
440
473
  .description('Initialize Devflow for Claude Code')
441
- .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())
442
475
  .option('--verbose', 'Show detailed installation output')
443
476
  .option('--plugin <names>', 'Install specific plugin(s), comma-separated (e.g., implement,code-review)')
444
477
  .option('--ambient', 'Enable ambient mode (orchestrator charter + plan handoff)')
@@ -456,8 +489,8 @@ export const initCommand = new Command('init')
456
489
  .option('--proxy', 'Enable external model routing (GPT models via your OpenAI/Codex subscription)')
457
490
  .option('--no-proxy', 'Disable external model routing')
458
491
  .option('--compliance <list>', 'Enable compliance with comma-separated framework IDs (e.g., gdpr,hipaa)')
459
- .option('--no-compliance', 'Disable compliance (artifacts removed; frameworks remembered for re-enable)')
460
- .option('--tracker <id>', 'Issue tracker provider: github, jira, or linear')
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')
461
494
  .option('--security <mode>', 'Security deny list location: user, managed, or none', /^(user|managed|none)$/i)
462
495
  .option('--hud-only', 'Install only the HUD (no plugins, hooks, or extras)')
463
496
  .option('--recommended', 'Apply recommended defaults after plugin selection (skip advanced prompts)')
@@ -483,30 +516,21 @@ export const initCommand = new Command('init')
483
516
  p.log.error('--reset and --plugin are mutually exclusive. Use --reset alone to restore defaults, or --plugin to update a specific plugin.');
484
517
  process.exit(1);
485
518
  }
486
- // Determine installation scope
487
- let scope = 'user';
488
- if (options.hudOnly) {
489
- // --hud-only: skip scope prompt, always user scope
490
- scope = 'user';
491
- }
492
- else if (options.scope) {
493
- const normalizedScope = options.scope.toLowerCase();
494
- if (normalizedScope !== 'user' && normalizedScope !== 'local') {
495
- p.log.error('Invalid scope. Use "user" or "local"');
496
- process.exit(1);
497
- }
498
- 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);
499
524
  }
500
- else if (!process.stdin.isTTY) {
501
- p.log.info('Non-interactive mode detected, using scope: user');
502
- 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);
503
530
  }
531
+ const { homeDir, claudeDir, devflowDir } = resolvedPaths.value;
504
532
  // --hud-only: install only HUD (skip plugins, hooks, extras)
505
533
  if (options.hudOnly) {
506
- // Resolve paths
507
- const paths = await getInstallationPaths(scope);
508
- const claudeDir = paths.claudeDir;
509
- const devflowDir = paths.devflowDir;
510
534
  // Save HUD config
511
535
  const existingHud = loadHudConfig();
512
536
  saveHudConfig({ enabled: true, detail: existingHud.detail });
@@ -521,7 +545,7 @@ export const initCommand = new Command('init')
521
545
  content = '{}';
522
546
  }
523
547
  const updated = addHudStatusLine(content, devflowDir);
524
- await fs.writeFile(settingsPath, updated, 'utf-8');
548
+ await writeSettingsFileAtomic(settingsPath, updated);
525
549
  }
526
550
  catch (error) {
527
551
  p.log.error(`Failed to update settings: ${error instanceof Error ? error.message : error}`);
@@ -544,7 +568,7 @@ export const initCommand = new Command('init')
544
568
  }
545
569
  catch { /* absent on fresh install — existingHudManifest stays null */ }
546
570
  try {
547
- await writeManifest(devflowDir, buildHudOnlyManifest(existingHudManifest, version, scope, new Date().toISOString()));
571
+ await writeManifest(devflowDir, buildHudOnlyManifest(existingHudManifest, version, new Date().toISOString()));
548
572
  }
549
573
  catch { /* non-fatal */ }
550
574
  p.log.success('HUD installed');
@@ -552,28 +576,30 @@ export const initCommand = new Command('init')
552
576
  p.outro(color.green('HUD-only install complete.'));
553
577
  return;
554
578
  }
555
- // ── Hoist reads: resolve paths early to compute InitSeed for pre-seeded prompts (Phase 4) ──
556
- // Best-effort: if path resolution fails here, seed falls back to fresh-install defaults.
557
- // The authoritative error gate for failed path resolution remains at the install-begins
558
- // spinner (see "Resolving paths" below). Hoisted above multiselect so Phase 4 can
559
- // pre-seed plugin/flag/feature prompts.
579
+ // ── Hoisted reads: the prior state that seeds the prompts (InitSeed, Phase 4) ──
560
580
  let existingManifest = null;
561
- 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;
562
598
  let earlySettingsJson = null;
563
- let earlyGitRoot = null;
564
599
  try {
565
- const earlyPaths = await getInstallationPaths(scope);
566
- existingManifest = await readManifest(earlyPaths.devflowDir);
567
- earlyGitRoot = earlyPaths.gitRoot ?? await getGitRoot();
568
- if (earlyGitRoot) {
569
- earlyProjectConfig = await readConfigIfPresent(earlyGitRoot);
570
- }
571
- try {
572
- earlySettingsJson = await fs.readFile(path.join(earlyPaths.claudeDir, 'settings.json'), 'utf-8');
573
- }
574
- catch { /* settings.json absent — treated as empty */ }
600
+ earlySettingsJson = await fs.readFile(path.join(claudeDir, 'settings.json'), 'utf-8');
575
601
  }
576
- catch { /* path resolution deferred to install-begins gate */ }
602
+ catch { /* settings.json absent — treated as empty */ }
577
603
  // --reset: factory reset — treat as a fresh install for all seeding and routing decisions.
578
604
  // The REAL existingManifest / earlySettingsJson are still used below for installedAt
579
605
  // preservation, upgrade messaging, and security deny-state detection. resolveResetGatedInputs
@@ -717,7 +743,8 @@ export const initCommand = new Command('init')
717
743
  // When no --plugin flag is given and a manifest exists, the seed carries the prior
718
744
  // selection (existing plugins ∪ new non-optional plugins not yet in knownPlugins).
719
745
  // Fresh non-interactive installs (no manifest) fall through to the default path
720
- // 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).
721
748
  if (!options.plugin && !process.stdin.isTTY && seedManifest !== null) {
722
749
  selectedPlugins = [...seed.workflowPlugins, ...seed.languagePlugins];
723
750
  }
@@ -791,7 +818,7 @@ export const initCommand = new Command('init')
791
818
  // --reset empties the settings snapshot via resolveResetGatedInputs so seed.flags['view-mode']
792
819
  // collapses to 'default', and explicit=true makes it take effect at settings write time.
793
820
  let viewModeExplicit = !!options.reset;
794
- let claudeignoreEnabled = !!earlyGitRoot;
821
+ let claudeignoreEnabled = !!gitRoot;
795
822
  let discoveredProjects = [];
796
823
  let safeDeleteAction = 'skip';
797
824
  let safeDeleteBlock = null;
@@ -914,10 +941,10 @@ export const initCommand = new Command('init')
914
941
  safeDeleteBlock = generateSafeDeleteBlock(shell, process.platform, trashCmd);
915
942
  }
916
943
  // Run independent I/O in parallel: project discovery + safe-delete version check
917
- const needsDiscovery = earlyGitRoot && scope === 'user';
944
+ const needsDiscovery = gitRoot !== null;
918
945
  const needsVersionCheck = safeDeleteBlock && profilePath;
919
946
  const [discoveredResult, installedVersionResult] = await Promise.all([
920
- needsDiscovery ? discoverProjectGitRoots() : Promise.resolve([]),
947
+ needsDiscovery ? discoverRepoRoots(claudeDir, homeDir) : Promise.resolve([]),
921
948
  needsVersionCheck ? getInstalledVersion(profilePath) : Promise.resolve(0),
922
949
  ]);
923
950
  discoveredProjects = discoveredResult;
@@ -1200,45 +1227,29 @@ export const initCommand = new Command('init')
1200
1227
  p.log.info(`Flags: ${activeCount} active — customize any time with 'devflow flags'`);
1201
1228
  }
1202
1229
  // .claudeignore prompt
1203
- if (earlyGitRoot) {
1204
- if (scope === 'user') {
1205
- discoveredProjects = await discoverProjectGitRoots();
1206
- p.note('Scans all projects Claude has worked on and creates a\n' +
1207
- '.claudeignore in each git repository. Excludes secrets,\n' +
1208
- 'API keys, dependencies, and build artifacts from context.', '.claudeignore');
1209
- if (discoveredProjects.length > 0) {
1210
- const maxShow = 5;
1211
- const projectLines = discoveredProjects.slice(0, maxShow).join('\n');
1212
- const overflow = discoveredProjects.length > maxShow
1213
- ? `\n... (${discoveredProjects.length - maxShow} more)`
1214
- : '';
1215
- p.note(projectLines + overflow, `Discovered ${discoveredProjects.length} projects`);
1216
- const claudeignoreChoice = await p.confirm({
1217
- message: `Install .claudeignore to ${discoveredProjects.length} projects? (Recommended)`,
1218
- initialValue: true,
1219
- });
1220
- if (p.isCancel(claudeignoreChoice)) {
1221
- p.cancel('Installation cancelled.');
1222
- process.exit(0);
1223
- }
1224
- claudeignoreEnabled = claudeignoreChoice;
1225
- }
1226
- else {
1227
- const claudeignoreChoice = await p.confirm({
1228
- message: 'Create .claudeignore? (Recommended)',
1229
- initialValue: true,
1230
- });
1231
- if (p.isCancel(claudeignoreChoice)) {
1232
- p.cancel('Installation cancelled.');
1233
- process.exit(0);
1234
- }
1235
- 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);
1236
1249
  }
1250
+ claudeignoreEnabled = claudeignoreChoice;
1237
1251
  }
1238
1252
  else {
1239
- p.note('Creates a .claudeignore in this project that excludes\n' +
1240
- 'secrets, API keys, dependencies, and build artifacts from\n' +
1241
- 'Claude\'s context window.', '.claudeignore');
1242
1253
  const claudeignoreChoice = await p.confirm({
1243
1254
  message: 'Create .claudeignore? (Recommended)',
1244
1255
  initialValue: true,
@@ -1279,8 +1290,8 @@ export const initCommand = new Command('init')
1279
1290
  }
1280
1291
  }
1281
1292
  }
1282
- // Security deny list placement (user scope + TTY only)
1283
- if (scope === 'user' && process.stdin.isTTY) {
1293
+ // Security deny list placement (TTY only)
1294
+ if (process.stdin.isTTY) {
1284
1295
  p.note('Devflow includes a security deny list that blocks dangerous\n' +
1285
1296
  'commands (rm -rf, sudo, eval, etc). It can be installed as a\n' +
1286
1297
  'read-only system file or in your editable settings.json.', 'Security Deny List');
@@ -1323,32 +1334,17 @@ export const initCommand = new Command('init')
1323
1334
  // ╭──────────────────────────────────────────────────────────╮
1324
1335
  // │ All prompts collected — installation begins │
1325
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);
1326
1341
  const s = p.spinner();
1327
- s.start('Resolving paths');
1328
- // Get installation paths
1329
- let claudeDir;
1330
- let devflowDir;
1331
- let gitRoot = null;
1332
- try {
1333
- const paths = await getInstallationPaths(scope);
1334
- claudeDir = paths.claudeDir;
1335
- devflowDir = paths.devflowDir;
1336
- gitRoot = paths.gitRoot ?? earlyGitRoot;
1342
+ s.start('Installing');
1343
+ if (upgrade?.isUpgrade) {
1344
+ s.message(`Upgrading from v${upgrade.previousVersion} to v${version}`);
1337
1345
  }
1338
- catch (error) {
1339
- s.stop('Path resolution failed');
1340
- p.log.error(`Path configuration error: ${error instanceof Error ? error.message : error}`);
1341
- process.exit(1);
1342
- }
1343
- // existingManifest was read early above (hoisted for seed computation); use it here for upgrade detection
1344
- if (existingManifest) {
1345
- const upgrade = detectUpgrade(version, existingManifest.version);
1346
- if (upgrade.isUpgrade) {
1347
- s.message(`Upgrading from v${upgrade.previousVersion} to v${version}`);
1348
- }
1349
- else if (upgrade.isSameVersion) {
1350
- s.message('Reinstalling same version');
1351
- }
1346
+ else if (upgrade?.isSameVersion) {
1347
+ s.message('Reinstalling same version');
1352
1348
  }
1353
1349
  // Detect current deny list state in user settings (read-only; write happens in security step)
1354
1350
  // Whether the managed settings file holds a Devflow deny entry — the security
@@ -1396,41 +1392,19 @@ export const initCommand = new Command('init')
1396
1392
  }
1397
1393
  // Validate target directory
1398
1394
  s.message('Validating target directory');
1399
- if (scope === 'local') {
1400
- try {
1401
- await fs.mkdir(claudeDir, { recursive: true });
1402
- }
1403
- catch (error) {
1404
- s.stop('Installation failed');
1405
- p.log.error(`Failed to create ${claudeDir}: ${error}`);
1406
- process.exit(1);
1407
- }
1395
+ try {
1396
+ await fs.access(claudeDir);
1408
1397
  }
1409
- else {
1410
- try {
1411
- await fs.access(claudeDir);
1412
- }
1413
- catch {
1414
- s.stop('Installation failed');
1415
- p.log.error(`Claude Code not detected at ${claudeDir}`);
1416
- p.log.info('Install from: https://claude.ai/download');
1417
- process.exit(1);
1418
- }
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);
1419
1403
  }
1420
1404
  // Resolve plugins and deduplication maps
1421
1405
  s.message('Installing components');
1422
1406
  const rootDir = getPackageRoot();
1423
- let pluginsToInstall = selectedPlugins.length > 0
1424
- ? DEVFLOW_PLUGINS.filter(p => selectedPlugins.includes(p.name))
1425
- : DEVFLOW_PLUGINS.filter(p => !p.optional);
1426
- const coreSkillsPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-core-skills');
1427
- if (pluginsToInstall.length > 0 && coreSkillsPlugin && !pluginsToInstall.includes(coreSkillsPlugin)) {
1428
- pluginsToInstall = [coreSkillsPlugin, ...pluginsToInstall];
1429
- }
1430
- const ambientPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-ambient');
1431
- if (ambientEnabled && ambientPlugin && !pluginsToInstall.includes(ambientPlugin)) {
1432
- pluginsToInstall.push(ambientPlugin);
1433
- }
1407
+ const pluginsToInstall = resolvePluginsToInstall(selectedPlugins, ambientEnabled, DEVFLOW_PLUGINS);
1434
1408
  // The EFFECTIVE selection — what the manifest will record, resolved here
1435
1409
  // rather than at manifest-write time because the skills install set is
1436
1410
  // derived from it. On a full install it is `pluginsToInstall`; on a partial
@@ -1450,12 +1424,11 @@ export const initCommand = new Command('init')
1450
1424
  // Migrations clean up ~/.devflow runtime data and never touch the installer's copy
1451
1425
  // targets, so their position relative to installViaFileCopy carries no dependency.
1452
1426
  // Migrations are always-run-unapplied: helpers short-circuit when the target data is
1453
- // absent, so fresh installs are safe no-ops. State lives at the home-dir ~/.devflow
1454
- // location regardless of install scope (D30).
1427
+ // absent, so fresh installs are safe no-ops. State lives at the machine root
1428
+ // ~/.devflow (D30).
1455
1429
  {
1456
1430
  const { runMigrations } = await import('../../core/migrations.js');
1457
- const userDevflowDir = path.join(os.homedir(), '.devflow');
1458
- 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);
1459
1432
  }
1460
1433
  // devflow-compliance was a selectable plugin in earlier releases; it is now a built-in
1461
1434
  // feature (devflow compliance --enable/--disable). If the prior manifest still lists it
@@ -1488,7 +1461,6 @@ export const initCommand = new Command('init')
1488
1461
  skillsMap,
1489
1462
  agentsMap,
1490
1463
  rulesMap,
1491
- trackerProvider,
1492
1464
  isPartialInstall: !!options.plugin,
1493
1465
  spinner: s,
1494
1466
  // Non-fatal install notices with no other channel (skipped symlinks in the
@@ -1514,13 +1486,13 @@ export const initCommand = new Command('init')
1514
1486
  manifest: { features: { compliance: { enabled: complianceEnabled, frameworks: complianceFrameworks }, rules: rulesEnabled } },
1515
1487
  warn: (msg) => p.log.warn(msg),
1516
1488
  });
1517
- // I41: emit legacy-upgrade notice when compliance is disabled AND pre-existing artifacts
1518
- // were found. After I09, the skill dir survives the orphan sweep (knownNames now unions
1519
- // FEATURE_OWNED_SKILLS), so convergeResult.removedPreexisting correctly fires for the
1520
- // skill path. hadComplianceRule covers the rule path (wiped by installViaFileCopy before
1521
- // 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.
1522
1494
  if (!complianceEnabled && (convergeResult.removedPreexisting || hadComplianceRule)) {
1523
- 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, ' +
1524
1496
  'run `devflow compliance --enable` to re-enable with your framework selection.');
1525
1497
  }
1526
1498
  }
@@ -1773,20 +1745,20 @@ export const initCommand = new Command('init')
1773
1745
  try {
1774
1746
  let content = await fs.readFile(settingsPath, 'utf-8');
1775
1747
  const original = content;
1776
- // Ambient hook — always remove-then-add to upgrade from legacy ambient-prompt → preamble
1777
- const cleanedForAmbient = await removeAmbientHook(content);
1778
- 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);
1779
1750
  // Capture hooks — always-on (like the context hook below), remove-then-add for
1780
1751
  // upgrade safety. Queue-append only (capture-prompt/capture-turn/capture-question);
1781
1752
  // each script gates its own per-queue write on the machine-wide switch, so there
1782
- // is no CLI-level enable/disable toggle here. MUST run before convergeMemoryHooks below
1783
- // so capture-turn lands before memory-worker in the Stop array (AC-C2 ordering:
1784
- // append-before-spawn).
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.
1785
1757
  const cleanedForCapture = removeCaptureHooks(content);
1786
1758
  content = addCaptureHooks(cleanedForCapture, devflowDir);
1787
1759
  // Memory hooks — Stop (memory-worker), SessionStart (session-start-memory),
1788
1760
  // PreCompact — through the same transform `devflow memory --enable/--disable`
1789
- // uses (D-FEATURES-MACHINE-WIDE). Learning agent (spawned via
1761
+ // uses (D-FEATURES-NARROW-ONLY). Learning agent (spawned via
1790
1762
  // session-start-context directive) handles decision/pitfall detection.
1791
1763
  // Knowledge is handled in-command via write-through (knowledge_writeback MDS partial).
1792
1764
  content = convergeMemoryHooks(content, memoryEnabled, devflowDir);
@@ -1843,7 +1815,7 @@ export const initCommand = new Command('init')
1843
1815
  if (proxyEnabled)
1844
1816
  content = applyProxyEnv(content, effectivePort);
1845
1817
  if (content !== original) {
1846
- await fs.writeFile(settingsPath, content, 'utf-8');
1818
+ await writeSettingsFileAtomic(settingsPath, content);
1847
1819
  if (verbose) {
1848
1820
  if (ambientEnabled)
1849
1821
  p.log.success('Ambient mode hook installed');
@@ -1858,28 +1830,33 @@ export const initCommand = new Command('init')
1858
1830
  p.log.warn(`Could not configure settings.json: ${err instanceof Error ? err.message : err}. ` +
1859
1831
  'Manifest records intended state; run devflow init again to retry.');
1860
1832
  }
1861
- // Write .devflow/config.json — facts about this repo, never a feature switch
1862
- // (memory/learning/knowledge are the manifest's alone, D-FEATURES-MACHINE-WIDE;
1863
- // the managed write drops their retired per-repo keys). A managed
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
1864
1838
  // read-modify-write, not a whole-file write: init owns only reviewPublication,
1865
1839
  // and every other key in the file — the hand-written per-repo `tracker`
1866
1840
  // override first among them — is carried from disk, under --reset too
1867
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).
1868
1843
  if (gitRoot) {
1869
- await writeManagedConfig(gitRoot, {
1844
+ const configWrite = await writeManagedConfig(gitRoot, {
1870
1845
  // reviewPublication has no prompt, so it is carried over from the
1871
1846
  // reset-gated snapshot rather than re-read from disk: seedConfig is null
1872
1847
  // under --reset, which is what collapses the field back to 'auto' with
1873
1848
  // every other feature (PF-015 — read the post-gate binding, not the file).
1874
1849
  reviewPublication: seedConfig?.reviewPublication ?? DEFAULT_CONFIG.reviewPublication,
1875
1850
  });
1851
+ if (!configWrite.ok)
1852
+ p.log.warn(formatManagedConfigWriteError(configWrite.error));
1876
1853
  }
1877
1854
  // Configure HUD
1878
1855
  const existingHud = loadHudConfig();
1879
1856
  saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail });
1880
1857
  // File extras
1881
1858
  if (claudeignoreEnabled) {
1882
- if (scope === 'user' && discoveredProjects.length > 0) {
1859
+ if (discoveredProjects.length > 0) {
1883
1860
  const results = await Promise.all(discoveredProjects.map(root => installClaudeignore(root, rootDir, verbose)));
1884
1861
  const created = results.filter(Boolean).length;
1885
1862
  if (created > 0) {
@@ -1894,18 +1871,12 @@ export const initCommand = new Command('init')
1894
1871
  }
1895
1872
  }
1896
1873
  // Deterministically ensure .devflow/ is gitignored at the repo root — independent
1897
- // of install scope and every feature toggle. The always-on ensure-root-gitignore
1874
+ // of every feature toggle. The always-on ensure-root-gitignore
1898
1875
  // hook covers projects that never re-run init; this covers the init-time path so a
1899
1876
  // fresh install never tracks .devflow/. Decoupled from memory (avoids PF-014).
1900
1877
  if (gitRoot) {
1901
1878
  await ensureDevflowGitignore(gitRoot, verbose);
1902
1879
  }
1903
- if (scope === 'local' && gitRoot) {
1904
- await updateGitignore(gitRoot, verbose);
1905
- }
1906
- if (scope === 'local') {
1907
- await createDocsStructure(verbose);
1908
- }
1909
1880
  // Safe-delete execution (decision was captured during prompt phase)
1910
1881
  if (safeDeleteAction === 'install' && safeDeleteBlock && profilePath) {
1911
1882
  await installToProfile(profilePath, safeDeleteBlock);
@@ -2068,7 +2039,7 @@ export const initCommand = new Command('init')
2068
2039
  logSummaryLines(formatSweepSummary(installReport));
2069
2040
  // Reference-overlay reporting: the overlay rewrites files inside an installed skill
2070
2041
  // the user may have shadowed, and reports any unit it had to leave alone (PF-015).
2071
- logSummaryLines(formatOverlaySummary(installReport, trackerProvider));
2042
+ logSummaryLines(formatOverlaySummary(installReport));
2072
2043
  // Skill-scoping reporting: a deselected skill is deleted and a dormant shadow
2073
2044
  // is inert, and neither is distinguishable from "never installed" on disk.
2074
2045
  //
@@ -2123,7 +2094,6 @@ export const initCommand = new Command('init')
2123
2094
  .map(plugin => `${color.yellow(plugin.name.padEnd(24))}${color.dim(plugin.description)}`)
2124
2095
  .join('\n');
2125
2096
  p.note(pluginsList, 'Installed plugins');
2126
- p.log.info(`Scope: ${scope}`);
2127
2097
  p.log.info(`Claude dir: ${claudeDir}`);
2128
2098
  p.log.info(`Devflow dir: ${devflowDir}`);
2129
2099
  const totalSkillDeclarations = pluginsToInstall.reduce((sum, p) => sum + p.skills.length, 0);
@@ -2139,7 +2109,7 @@ export const initCommand = new Command('init')
2139
2109
  // derived from it — one binding, so the manifest can never record a
2140
2110
  // selection other than the one the assets were installed for.
2141
2111
  plugins: effectivePluginNames,
2142
- scope,
2112
+ scope: 'user',
2143
2113
  // Snapshot of known plugin names at this install — used by resolveSeedPlugins on next init
2144
2114
  // to detect new non-optional plugins and auto-adopt them.
2145
2115
  knownPlugins: DEVFLOW_PLUGINS.map(p => p.name),
@@ -2169,17 +2139,13 @@ export const initCommand = new Command('init')
2169
2139
  updatedAt: now,
2170
2140
  };
2171
2141
  // ── Manifest write + tracker selection lifecycle (the ONE call site) ──────
2172
- // persistManifestThenConvergeTracker owns the ordering invariant: the three
2142
+ // persistManifestThenConvergeTracker owns the ordering invariant: the
2173
2143
  // tracker file-lifecycle owners in src/core/tracker.ts converge only against
2174
2144
  // a provider the manifest actually persisted (D-TRACKER-CONVERGE, PF-015).
2175
2145
  const trackerLifecycle = await persistManifestThenConvergeTracker({
2176
2146
  devflowDir,
2177
2147
  claudeDir,
2178
2148
  manifestData,
2179
- // The REAL manifest, not the --reset-gated seed: under --reset the resolved
2180
- // provider collapses to github while the prior provider is still jira/linear,
2181
- // and that IS a transition the stale-file rename has to fire on.
2182
- previousProvider: existingManifest?.features.tracker.provider,
2183
2149
  io: buildTrackerLifecycleIO(),
2184
2150
  });
2185
2151
  for (const msg of trackerLifecycle.messages) {
@@ -2191,10 +2157,22 @@ export const initCommand = new Command('init')
2191
2157
  // Only now that the machine-wide switch is on disk (D-INIT-DRAIN-AFTER-SWITCH).
2192
2158
  await drainDisabledFeatureQueues({
2193
2159
  gitRoot,
2160
+ ledgerRoot: learningEnabled || gitRoot === null ? null : await getLedgerRoot(),
2194
2161
  memoryEnabled,
2195
2162
  learningEnabled,
2196
2163
  manifestWritten: trackerLifecycle.manifestWritten,
2197
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
+ }
2198
2176
  // Name the active provider and what the selection moved. The reference
2199
2177
  // counts come from the install report rather than being recomputed: the
2200
2178
  // overlay is what actually installed and pruned them, so a second count