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
@@ -4,12 +4,13 @@ import * as path from 'path';
4
4
  import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
7
- import { syncManifestFeature } from '../../core/manifest.js';
8
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
7
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
9
8
  import { discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
10
9
  import { getGitRoot } from '../../core/git.js';
11
10
  import { getMemoryDir, getPendingTurnsPath, getPendingTurnsProcessingPath, } from '../../core/project-paths.js';
12
- import { updateFeature, isFeatureEnabled } from '../../core/feature-config.js';
11
+ import { HOOKS_DIR_SUFFIX, devflowHookOwner, endsWithAny, ensureHook, hasHook, removeHooks, runHookCommand, runHookSuffix, } from '../../targets/claude-code/hooks.js';
12
+ import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
13
+ import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
13
14
  /**
14
15
  * Map of hook event type → filename marker for the memory hooks.
15
16
  * Three hooks total: Stop, SessionStart, PreCompact.
@@ -19,10 +20,11 @@ import { updateFeature, isFeatureEnabled } from '../../core/feature-config.js';
19
20
  * at the hook-registration level), and decisions detection is a SessionStart-spawned
20
21
  * detached worker rather than a SessionEnd hook (see legacy-hooks.ts).
21
22
  *
22
- * Stop-array ordering contract: memory-worker MUST be registered AFTER capture-turn
23
- * in the Stop hook array (append-before-spawn — memory-worker's throttle/spawn
24
- * decision assumes the current turn was already appended by capture-turn earlier
25
- * in the same Stop event). Enforced by init.ts's registration order, not here.
23
+ * Stop-event concurrency: Claude Code runs one event's hooks in parallel, so
24
+ * memory-worker can spawn background-memory-update before capture-turn has
25
+ * appended this turn's assistant row. The worker tolerates that — a queue that
26
+ * holds only user rows is left in place and the LLM run skipped
27
+ * (D-QUEUE-NO-ORPHAN-DELETE) — so nothing here depends on hook order.
26
28
  */
27
29
  const MEMORY_HOOK_CONFIG = {
28
30
  Stop: 'memory-worker',
@@ -30,14 +32,32 @@ const MEMORY_HOOK_CONFIG = {
30
32
  PreCompact: 'pre-compact-memory',
31
33
  };
32
34
  /**
33
- * Legacy hook filename markers from prior architectures.
34
- * Used by removeMemoryHooks to clean up hooks from upgrading users.
35
+ * The command endings of the memory-era hooks earlier releases registered, per
36
+ * event. Removed by removeMemoryHooks so an upgrade leaves no hook pointing at a
37
+ * script that no longer exists; never counted as a current memory hook.
38
+ *
39
+ * D-EXACT-HOOK-OWNER (hooks.ts): matched as command ENDINGS under any directory —
40
+ * the v1 (≤ v1.2) direct `.sh` scripts, then the retired `run-hook` markers of the
41
+ * prompt-capture, learning, decisions, knowledge-refresh, sidecar and dream
42
+ * pipelines — never as substrings, so a user's hook that mentions one is theirs.
35
43
  */
36
- const LEGACY_HOOK_MARKERS = {
37
- UserPromptSubmit: ['prompt-capture-memory', 'sidecar-dispatch', 'dream-dispatch'],
38
- Stop: ['stop-update-memory', 'stop-update-learning', 'sidecar-capture', 'dream-capture'],
39
- SessionEnd: ['session-end-learning', 'session-end-decisions', 'session-end-knowledge-refresh', 'sidecar-evaluate', 'dream-evaluate'],
44
+ const LEGACY_HOOK_SUFFIXES = {
45
+ UserPromptSubmit: ['prompt-capture-memory', 'sidecar-dispatch', 'dream-dispatch'].map(runHookSuffix),
46
+ Stop: [
47
+ `${HOOKS_DIR_SUFFIX}stop-update-memory.sh`,
48
+ ...['stop-update-memory', 'stop-update-learning', 'sidecar-capture', 'dream-capture'].map(runHookSuffix),
49
+ ],
50
+ SessionStart: [`${HOOKS_DIR_SUFFIX}session-start-memory.sh`],
51
+ PreCompact: [`${HOOKS_DIR_SUFFIX}pre-compact-memory.sh`],
52
+ SessionEnd: [
53
+ 'session-end-learning', 'session-end-decisions', 'session-end-knowledge-refresh',
54
+ 'sidecar-evaluate', 'dream-evaluate',
55
+ ].map(runHookSuffix),
40
56
  };
57
+ /** D-EXACT-HOOK-OWNER: the current memory hook for `marker` (hooks.ts). */
58
+ function isMemoryHook(marker) {
59
+ return devflowHookOwner([marker]);
60
+ }
41
61
  /**
42
62
  * Add all 3 memory hooks (Stop, SessionStart, PreCompact) to settings JSON.
43
63
  * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 3 present.
@@ -47,28 +67,10 @@ export function addMemoryHooks(settingsJson, devflowDir) {
47
67
  if (hasMemoryHooks(settings)) {
48
68
  return settingsJson;
49
69
  }
50
- if (!settings.hooks) {
51
- settings.hooks = {};
52
- }
53
70
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
54
- const existing = settings.hooks[hookType] ?? [];
55
- const alreadyPresent = existing.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)));
56
- if (!alreadyPresent) {
57
- const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${marker}`;
58
- const newEntry = {
59
- hooks: [
60
- {
61
- type: 'command',
62
- command: hookCommand,
63
- timeout: 10,
64
- },
65
- ],
66
- };
67
- if (!settings.hooks[hookType]) {
68
- settings.hooks[hookType] = [];
69
- }
70
- settings.hooks[hookType].push(newEntry);
71
- }
71
+ ensureHook(settings, hookType, isMemoryHook(marker), {
72
+ hooks: [{ type: 'command', command: runHookCommand(devflowDir, marker), timeout: 10 }],
73
+ });
72
74
  }
73
75
  return JSON.stringify(settings, null, 2) + '\n';
74
76
  }
@@ -81,36 +83,16 @@ export function addMemoryHooks(settingsJson, devflowDir) {
81
83
  export function removeMemoryHooks(input) {
82
84
  const settingsJson = typeof input === 'string' ? input : JSON.stringify(input);
83
85
  const settings = typeof input === 'string' ? JSON.parse(input) : structuredClone(input);
84
- if (!settings.hooks) {
85
- return settingsJson;
86
- }
86
+ // Evaluate every removal into a local — never short-circuit (PF-015).
87
87
  let changed = false;
88
88
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
89
- if (!settings.hooks[hookType]) {
90
- continue;
91
- }
92
- const before = settings.hooks[hookType].length;
93
- settings.hooks[hookType] = settings.hooks[hookType].filter((matcher) => !matcher.hooks.some((h) => h.command.includes(marker)));
94
- if (settings.hooks[hookType].length !== before) {
95
- changed = true;
96
- }
97
- if (settings.hooks[hookType].length === 0) {
98
- delete settings.hooks[hookType];
99
- }
100
- }
101
- // Remove legacy pre-dream hooks from upgrading users
102
- for (const [hookType, markers] of Object.entries(LEGACY_HOOK_MARKERS)) {
103
- if (!settings.hooks[hookType])
104
- continue;
105
- const before = settings.hooks[hookType].length;
106
- settings.hooks[hookType] = settings.hooks[hookType].filter((matcher) => !matcher.hooks.some((h) => markers.some((m) => h.command.includes(m))));
107
- if (settings.hooks[hookType].length !== before)
108
- changed = true;
109
- if (settings.hooks[hookType].length === 0)
110
- delete settings.hooks[hookType];
89
+ const removed = removeHooks(settings, hookType, isMemoryHook(marker));
90
+ changed = changed || removed;
111
91
  }
112
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
113
- delete settings.hooks;
92
+ // Remove the memory-era hooks of earlier releases from upgrading users
93
+ for (const [hookType, suffixes] of Object.entries(LEGACY_HOOK_SUFFIXES)) {
94
+ const removed = removeHooks(settings, hookType, endsWithAny(suffixes));
95
+ changed = changed || removed;
114
96
  }
115
97
  if (!changed) {
116
98
  return settingsJson;
@@ -129,18 +111,48 @@ export function hasMemoryHooks(input) {
129
111
  */
130
112
  export function countMemoryHooks(input) {
131
113
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
132
- if (!settings.hooks) {
133
- return 0;
134
- }
135
114
  let count = 0;
136
115
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
137
- const matchers = settings.hooks[hookType] ?? [];
138
- if (matchers.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)))) {
116
+ if (hasHook(settings, hookType, isMemoryHook(marker)))
139
117
  count++;
140
- }
141
118
  }
142
119
  return count;
143
120
  }
121
+ /**
122
+ * Converge the memory hooks in a settings JSON string to `enabled`. Pure.
123
+ *
124
+ * D-FEATURES-NARROW-ONLY: the ONE settings transform for the memory feature,
125
+ * shared by `devflow init` (inside its single settings read-modify-write pass)
126
+ * and `devflow memory --enable/--disable`, so the two controls of the same
127
+ * machine-wide switch leave settings.json byte-for-byte alike. Always
128
+ * remove-then-add, which also upgrades an older hook format (e.g. `.sh` →
129
+ * `run-hook`) in place.
130
+ *
131
+ * Stop-array position (AC-C2): memory-worker is appended after whatever the
132
+ * Stop array already holds, so it lands after capture-turn — init registers the
133
+ * capture hooks earlier in the same pass, and on a standalone toggle they are
134
+ * already present. The position keeps settings.json identical across init and
135
+ * the toggle; it sequences nothing at run time, where the Stop hooks run in
136
+ * parallel.
137
+ */
138
+ export function convergeMemoryHooks(settingsJson, enabled, devflowDir) {
139
+ const cleaned = removeMemoryHooks(settingsJson);
140
+ return enabled ? addMemoryHooks(cleaned, devflowDir) : cleaned;
141
+ }
142
+ /**
143
+ * Drain a project's pending memory queue (and a claimed batch) so stale turns
144
+ * are not processed when memory is next switched on. Shared by `devflow init
145
+ * --no-memory` and `devflow memory --disable`. ENOENT-tolerant; any other
146
+ * error propagates to the command boundary, like drainLearningQueue.
147
+ */
148
+ export async function drainMemoryQueue(projectRoot) {
149
+ const ignoreMissing = (e) => { if (e.code !== 'ENOENT')
150
+ throw e; };
151
+ await Promise.all([
152
+ fs.unlink(getPendingTurnsPath(projectRoot)).catch(ignoreMissing),
153
+ fs.unlink(getPendingTurnsProcessingPath(projectRoot)).catch(ignoreMissing),
154
+ ]);
155
+ }
144
156
  /**
145
157
  * Returns true if the given project root contains a `.devflow/memory/` directory.
146
158
  * Treats unexpected errors (e.g. EACCES) as absent to avoid false positives.
@@ -195,16 +207,16 @@ export async function cleanQueueFiles(projectPaths) {
195
207
  }
196
208
  export const memoryCommand = new Command('memory')
197
209
  .description('Enable, disable, or clean up working memory (session context preservation)')
198
- .option('--enable', 'Enable working memory')
199
- .option('--disable', 'Disable working memory')
210
+ .option('--enable', 'Enable working memory in every project (a repository can opt out)')
211
+ .option('--disable', 'Disable working memory in every project')
200
212
  .option('--status', 'Show current state')
201
213
  .option('--clear', 'Clean up queue files from projects')
202
214
  .action(async (options) => {
203
215
  const hasFlag = options.enable || options.disable || options.status || options.clear;
204
216
  if (!hasFlag) {
205
217
  p.intro(color.bgCyan(color.white(' Working Memory ')));
206
- p.note(`${color.cyan('devflow memory --enable')} Add memory hooks\n` +
207
- `${color.cyan('devflow memory --disable')} Remove memory hooks\n` +
218
+ p.note(`${color.cyan('devflow memory --enable')} Enable working memory (every project; a repository can opt out)\n` +
219
+ `${color.cyan('devflow memory --disable')} Disable working memory (every project)\n` +
208
220
  `${color.cyan('devflow memory --status')} Check current state\n` +
209
221
  `${color.cyan('devflow memory --clear')} Clean up queue files`, 'Usage');
210
222
  p.outro(color.dim('Memory hooks provide automatic session context preservation'));
@@ -213,7 +225,7 @@ export const memoryCommand = new Command('memory')
213
225
  if (options.clear) {
214
226
  p.intro(color.bgCyan(color.white(' Memory Cleanup ')));
215
227
  // Discover current project and all known projects in parallel
216
- const [gitRoots, gitRoot] = await Promise.all([discoverProjectGitRoots(), getGitRoot()]);
228
+ const [gitRoots, gitRoot] = await Promise.all([discoverProjectGitRoots(getClaudeDirectory()), getGitRoot()]);
217
229
  const [projectsWithMemory, currentProjectHasMem] = await Promise.all([
218
230
  filterProjectsWithMemory(gitRoots),
219
231
  gitRoot ? hasMemoryDir(gitRoot) : Promise.resolve(false),
@@ -260,90 +272,77 @@ export const memoryCommand = new Command('memory')
260
272
  : 'No queue files found to clean');
261
273
  return;
262
274
  }
263
- const claudeDir = getClaudeDirectory();
264
- const settingsPath = path.join(claudeDir, 'settings.json');
275
+ const settingsPath = path.join(getClaudeDirectory(), 'settings.json');
276
+ const devflowDir = getDevFlowDirectory();
265
277
  let settingsContent;
266
278
  try {
267
279
  settingsContent = await fs.readFile(settingsPath, 'utf-8');
268
280
  }
269
281
  catch {
270
- if (options.status) {
271
- p.log.info('Working memory: disabled (no settings.json found)');
272
- return;
273
- }
274
- // Create minimal settings.json
275
282
  settingsContent = '{}';
276
283
  }
277
- // Resolve current project root for feature config
278
- const gitRoot = await getGitRoot();
279
284
  if (options.status) {
280
- if (!gitRoot) {
281
- p.log.info(`Working memory: ${color.dim('disabled')} (not in a git project)`);
282
- return;
283
- }
285
+ // D-FEATURES-NARROW-ONLY: the machine switch, the manifest's, is reported
286
+ // first. The hook count is reported beside it because the hooks are how that
287
+ // switch takes effect. A repository layer can only narrow it, and says so on
288
+ // a line of its own — only when it does, so the output is otherwise unchanged.
289
+ const enabled = await readMachineFeature(devflowDir, 'memory');
284
290
  const count = countMemoryHooks(settingsContent);
285
291
  const total = Object.keys(MEMORY_HOOK_CONFIG).length;
286
- // Also check feature config: hooks may be registered but feature toggled off
287
- const featureEnabled = await isFeatureEnabled(gitRoot, 'memory');
288
- if (count === total && featureEnabled) {
292
+ if (enabled && count === total) {
289
293
  p.log.info(`Working memory: ${color.green('enabled')} (${total}/${total} hooks)`);
290
294
  }
291
- else if (count === 0 || !featureEnabled) {
295
+ else if (!enabled) {
292
296
  p.log.info(`Working memory: ${color.dim('disabled')}`);
293
297
  }
294
298
  else {
295
- p.log.info(`Working memory: ${color.yellow(`partial (${count}/${total} hooks)`)} — run --enable to fix`);
299
+ p.log.info(`Working memory: ${color.yellow(`enabled, but ${count}/${total} hooks registered`)} — ` +
300
+ `run ${color.cyan('devflow memory --enable')} to fix`);
296
301
  }
302
+ const settingsModule = loadSettingsModule();
303
+ const narrowed = enabled ? narrowedSwitchLabel(settingsModule, { dir: process.cwd() }, 'memory') : null;
304
+ if (narrowed !== null)
305
+ p.log.info(`Effective here: ${color.yellow(narrowed)}`);
306
+ const trackedWarning = personalConfigTrackedWarning(settingsModule, { dir: process.cwd() });
307
+ if (trackedWarning !== null)
308
+ p.log.warn(trackedWarning);
297
309
  return;
298
310
  }
299
- const devflowDir = getDevFlowDirectory();
300
- if (options.enable) {
301
- // D: --enable both installs hooks AND writes feature config, while --disable only
302
- // writes feature config. This asymmetry is intentional: capture hooks are shared
303
- // across features (memory, learning, decisions) and must never be removed by a
304
- // single-feature disable. --enable must still install them on first use.
305
- const alreadyHasHooks = hasMemoryHooks(settingsContent);
306
- const alreadyEnabled = alreadyHasHooks && (gitRoot ? await isFeatureEnabled(gitRoot, 'memory') : false);
307
- if (alreadyEnabled) {
308
- p.log.info('Working memory already enabled');
309
- }
310
- else if (alreadyHasHooks) {
311
- // Hooks are registered but config has memory:false — re-enable via config
312
- p.log.success('Working memory enabled — configuration updated');
313
- p.log.info(color.dim('Session context will be automatically preserved across conversations'));
314
- }
315
- else {
316
- const updated = addMemoryHooks(settingsContent, devflowDir);
317
- await writeFileAtomicExclusive(settingsPath, updated);
318
- p.log.success('Working memory enabled — hooks registered');
319
- p.log.info(color.dim('Session context will be automatically preserved across conversations'));
320
- }
321
- // Update config to enable memory feature
322
- if (gitRoot) {
323
- await updateFeature(gitRoot, 'memory', true);
324
- }
325
- await syncManifestFeature(getDevFlowDirectory(), 'memory', true);
311
+ // --enable / --disable: the machine-wide switch, converged exactly as
312
+ // `devflow init --memory / --no-memory` converges it (D-FEATURES-NARROW-ONLY).
313
+ // The settings transform runs FIRST: it is the step that can reject its
314
+ // input (malformed JSON), and the switch must not be recorded unless the
315
+ // hooks that enact it can follow.
316
+ const enabled = options.enable === true;
317
+ let converged;
318
+ try {
319
+ converged = convergeMemoryHooks(settingsContent, enabled, devflowDir);
320
+ }
321
+ catch (err) {
322
+ p.log.error(`Could not update ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
323
+ process.exitCode = 1;
326
324
  return;
327
325
  }
328
- if (options.disable) {
329
- // Hooks remain registered (shared with other features).
330
- // Disable by writing memory: false to config only — hooks are not removed.
331
- if (gitRoot) {
332
- await updateFeature(gitRoot, 'memory', false);
333
- // Drain orphaned queue files so stale turns don't process on re-enable
334
- await Promise.all([
335
- fs.unlink(getPendingTurnsPath(gitRoot)).catch((e) => { if (e.code !== 'ENOENT')
336
- throw e; }),
337
- fs.unlink(getPendingTurnsProcessingPath(gitRoot)).catch((e) => { if (e.code !== 'ENOENT')
338
- throw e; }),
339
- ]);
340
- await syncManifestFeature(getDevFlowDirectory(), 'memory', false);
341
- p.log.success('Working memory disabled — configuration updated');
342
- }
343
- else {
344
- p.log.warn('Could not resolve git root — configuration not updated');
345
- }
326
+ const recorded = await writeMachineFeature(devflowDir, 'memory', enabled);
327
+ if (!recorded.ok) {
328
+ p.log.error(`Devflow is not installed on this machine — run ${color.cyan('devflow init')} first`);
329
+ process.exitCode = 1;
346
330
  return;
347
331
  }
332
+ if (converged !== settingsContent) {
333
+ await writeSettingsFileAtomic(settingsPath, converged);
334
+ }
335
+ if (enabled) {
336
+ p.log.success('Working memory enabled in every project (a repository can opt out)');
337
+ p.log.info(color.dim('Session context will be automatically preserved across conversations'));
338
+ return;
339
+ }
340
+ // Drain the current project's queue, as init does. Outside a git project
341
+ // there is no project queue to drain, and the switch itself still applies.
342
+ const gitRoot = await getGitRoot();
343
+ if (gitRoot) {
344
+ await drainMemoryQueue(gitRoot);
345
+ }
346
+ p.log.success('Working memory disabled in every project');
348
347
  });
349
348
  //# sourceMappingURL=memory.js.map
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Shared wizard prompt-IO seam for devflow init wizard steps.
3
3
  *
4
- * ADR-019 corollary (one-definition seam): PromptOutcome and WizardPromptIO
5
- * were byte-identical duplicates across attribution-prompts.ts and
6
- * compliance-prompts.ts (architecture-03 / consistency-06). They are defined
7
- * ONCE here and re-used via import.
4
+ * One-definition seam: PromptOutcome and WizardPromptIO were byte-identical
5
+ * duplicates across attribution-prompts.ts and compliance-prompts.ts
6
+ * (architecture-03 / consistency-06). They are defined ONCE here and re-used
7
+ * via import.
8
8
  *
9
9
  * D-PROMPT-IO: WizardPromptIO is the base DI seam for all two-action wizard
10
10
  * steps (note + boolean select). Modules that add a third prompt extend this
@@ -24,7 +24,7 @@ import * as p from '@clack/prompts';
24
24
  import color from 'picocolors';
25
25
  import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, proxyBaseUrl, proxyJsonExists, resolveProxyBin, DEFAULT_PROXY_PORT, } from '../../core/proxy-state.js';
26
26
  import { syncManifestFeature, readManifest } from '../../core/manifest.js';
27
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
27
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
28
28
  import { scrubChildEnv, openProxyLog, rotateProxyLogIfLarge } from '../../core/proxy-log.js';
29
29
  import { inspectCodexAuth, classifyCodexAuthReadError } from '../../core/codex-auth-inspect.js';
30
30
  import { stripAnsi } from '../../hud/colors.js';
@@ -32,6 +32,7 @@ import { reapplyAgentMapping, revertExternalAgents, countExternalMappedAgents, r
32
32
  import { getExternalModelsCached, discoverExternalModels, } from '../../core/model-discovery.js';
33
33
  import { modelCacheDir } from '../../core/cache.js';
34
34
  import { getClaudeDirectory, getDevFlowDirectory, getHomeDirectory, } from '../../targets/claude-code/claude-paths.js';
35
+ import { devflowHookOwner, ensureHook, hasHook, removeHooks, runHookCommand, } from '../../targets/claude-code/hooks.js';
35
36
  function Ok(value) {
36
37
  return { ok: true, value };
37
38
  }
@@ -39,8 +40,17 @@ function Err(error) {
39
40
  return { ok: false, error };
40
41
  }
41
42
  // ─── Constants ────────────────────────────────────────────────────────────────
42
- /** Marker used to identify ensure-proxy hook entries. */
43
+ /** The run-hook marker of the ensure-proxy hook. */
43
44
  const PROXY_HOOK_MARKER = 'ensure-proxy';
45
+ /**
46
+ * D-EXACT-HOOK-OWNER: the ensure-proxy hook is devflow's only when its command ends
47
+ * in `/scripts/hooks/run-hook ensure-proxy` (hooks.ts), under any directory — the one
48
+ * form it has ever been registered in. A user's hook that merely mentions the word
49
+ * is theirs.
50
+ */
51
+ const isProxyHook = devflowHookOwner([PROXY_HOOK_MARKER]);
52
+ /** The events the ensure-proxy hook is registered on. */
53
+ const PROXY_HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit'];
44
54
  /** Pattern matching our relay's ANTHROPIC_BASE_URL value. */
45
55
  const OUR_BASE_URL_PATTERN = /^http:\/\/127\.0\.0\.1:\d+$/;
46
56
  /** Timeout for individual TCP connect probes and HTTP health checks (ms). */
@@ -166,34 +176,6 @@ function _stripProxyEnvFromObject(settings, managedPort) {
166
176
  delete s.env;
167
177
  return removedUrl || hadWindowVar; // OR the locals — never compose with || inline (PF-015)
168
178
  }
169
- /** Internal: add ensure-proxy hook to one event. Returns true when added. */
170
- function _ensureProxyHook(settings, eventName, hookCmd) {
171
- const existing = settings.hooks?.[eventName];
172
- if (existing?.some((m) => m.hooks.some((h) => h.command.includes(PROXY_HOOK_MARKER)))) {
173
- return false;
174
- }
175
- settings.hooks ??= {};
176
- settings.hooks[eventName] ??= [];
177
- const entry = {
178
- hooks: [{ type: 'command', command: hookCmd, timeout: 15 }],
179
- };
180
- settings.hooks[eventName].push(entry);
181
- return true;
182
- }
183
- /** Internal: remove ensure-proxy hooks from one event. Returns true when removed. */
184
- function _filterProxyHooks(settings, eventName) {
185
- if (!settings.hooks?.[eventName])
186
- return false;
187
- const before = settings.hooks[eventName].length;
188
- settings.hooks[eventName] = settings.hooks[eventName].filter((m) => !m.hooks.some((h) => h.command.includes(PROXY_HOOK_MARKER)));
189
- if (settings.hooks[eventName].length === before)
190
- return false;
191
- if (settings.hooks[eventName].length === 0)
192
- delete settings.hooks[eventName];
193
- if (Object.keys(settings.hooks).length === 0)
194
- delete settings.hooks;
195
- return true;
196
- }
197
179
  // ─── Pure env functions (exported for testing and cross-module reuse) ─────────
198
180
  /**
199
181
  * Apply ANTHROPIC_BASE_URL=http://127.0.0.1:<port> to settings JSON.
@@ -251,20 +233,21 @@ export function readProxyEnvState(settingsJson, port) {
251
233
  * Mutates settings in place. Returns true when any hook was added.
252
234
  */
253
235
  export function addProxyHooks(settings, devflowDir) {
254
- const hookCmd = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ' ' + PROXY_HOOK_MARKER;
255
- const addedSession = _ensureProxyHook(settings, 'SessionStart', hookCmd);
256
- const addedPrompt = _ensureProxyHook(settings, 'UserPromptSubmit', hookCmd);
236
+ const command = runHookCommand(devflowDir, PROXY_HOOK_MARKER);
237
+ // Evaluate each event into its own local — never short-circuit (PF-015).
238
+ const [addedSession, addedPrompt] = PROXY_HOOK_EVENTS.map((event) => ensureHook(settings, event, isProxyHook, { hooks: [{ type: 'command', command, timeout: 15 }] }));
257
239
  return addedSession || addedPrompt;
258
240
  }
259
241
  /**
260
242
  * Remove ensure-proxy hooks from all events.
261
243
  * Idempotent — no-op when hooks are not present.
262
- * Preserves other hooks. Cleans empty arrays/objects.
244
+ * Removes single hooks, so the other hooks of a shared matcher group stay in place
245
+ * (D-EXACT-HOOK-OWNER). Cleans empty arrays/objects.
263
246
  * Mutates settings in place. Returns true when any hook was removed.
264
247
  */
265
248
  export function removeProxyHooks(settings) {
266
- const removedSession = _filterProxyHooks(settings, 'SessionStart');
267
- const removedPrompt = _filterProxyHooks(settings, 'UserPromptSubmit');
249
+ // Evaluate each event into its own local — never short-circuit (PF-015).
250
+ const [removedSession, removedPrompt] = PROXY_HOOK_EVENTS.map((event) => removeHooks(settings, event, isProxyHook));
268
251
  return removedSession || removedPrompt;
269
252
  }
270
253
  /**
@@ -319,8 +302,7 @@ export function applyProxyTeardownToSettings(settings, managedPort) {
319
302
  */
320
303
  export function hasProxyHooks(input) {
321
304
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
322
- const check = (eventName) => settings.hooks?.[eventName]?.some((m) => m.hooks.some((h) => h.command.includes(PROXY_HOOK_MARKER))) === true;
323
- return check('SessionStart') || check('UserPromptSubmit');
305
+ return PROXY_HOOK_EVENTS.some((event) => hasHook(settings, event, isProxyHook));
324
306
  }
325
307
  // ─── Health-check identity helper ────────────────────────────────────────────
326
308
  /**
@@ -747,7 +729,7 @@ export async function runPostSpawnVerification(binPath, configPath, logPath, spa
747
729
  * Perform the single atomic settings.json pass for enable:
748
730
  * strip old hooks + env, then apply new hooks + env in one write.
749
731
  *
750
- * The writeFileAtomicExclusive call is guarded — ENOSPC/EACCES returns Err
732
+ * The writeSettingsFileAtomic call is guarded — ENOSPC/EACCES returns Err
751
733
  * instead of crashing with an unhandled rejection.
752
734
  *
753
735
  * Returns Ok(undefined) on success, Err(reason) on hard failure.
@@ -777,7 +759,7 @@ async function applyEnableSettingsPass(settingsPath, devflowDir, port) {
777
759
  addProxyHooks(parsedSettings, devflowDir);
778
760
  _applyProxyEnvToObject(parsedSettings, port);
779
761
  try {
780
- await writeFileAtomicExclusive(settingsPath, JSON.stringify(parsedSettings, null, 2) + '\n');
762
+ await writeSettingsFileAtomic(settingsPath, JSON.stringify(parsedSettings, null, 2) + '\n');
781
763
  }
782
764
  catch (err) {
783
765
  return Err(`Could not write settings.json: ${err instanceof Error ? err.message : String(err)}`);
@@ -1465,7 +1447,7 @@ async function runDisable() {
1465
1447
  if (changed) {
1466
1448
  // Guard ENOSPC/EACCES — unhandled rejection leaves proxy in partial state
1467
1449
  try {
1468
- await writeFileAtomicExclusive(settingsPath, JSON.stringify(parsedSettings, null, 2) + '\n');
1450
+ await writeSettingsFileAtomic(settingsPath, JSON.stringify(parsedSettings, null, 2) + '\n');
1469
1451
  }
1470
1452
  catch (err) {
1471
1453
  p.log.error(`Could not write settings.json: ${err instanceof Error ? err.message : String(err)}`);