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
@@ -24,9 +24,6 @@ import { prefixSkillName } from '../../core/plugins.js';
24
24
  *
25
25
  * Pure function — returns lines, logs nothing (applies ADR-013).
26
26
  *
27
- * @param provider - The resolved tracker provider the overlay converged to. The
28
- * count alone cannot say WHICH mechanics are installed, and after the install
29
- * became selection-scoped that is the number's whole meaning.
30
27
  * @param skillName - Bare name of the skill hosting the generated references,
31
28
  * rendered `devflow:`-prefixed. Defaults to the core constant the build path and
32
29
  * the installer's overlay trigger both read, so the renderer is never a third
@@ -34,14 +31,13 @@ import { prefixSkillName } from '../../core/plugins.js';
34
31
  * describes, where changing the answer means finding every retyped spelling and
35
32
  * nothing fails if one is missed.
36
33
  */
37
- export function formatOverlaySummary(report, provider, skillName = SKILL_REFS_SKILL_NAME) {
34
+ export function formatOverlaySummary(report, skillName = SKILL_REFS_SKILL_NAME) {
38
35
  const lines = [];
39
36
  if (report.overlaidRefs.length > 0) {
40
- const scope = provider === undefined ? '' : ` (${provider} tracker mechanics)`;
41
37
  lines.push({
42
38
  level: 'info',
43
39
  message: `Installed ${report.overlaidRefs.length} generated skill reference(s) for ` +
44
- prefixSkillName(skillName) + scope,
40
+ prefixSkillName(skillName),
45
41
  });
46
42
  }
47
43
  for (const failure of report.overlayFailures) {
@@ -94,16 +90,15 @@ export function describeOverlayFailureState(state) {
94
90
  /**
95
91
  * The install summary's tracker rows.
96
92
  *
97
- * Two facts the previous summary never stated, and after the install became
98
- * selection-scoped both of them decide what the user actually has:
93
+ * Two facts the install has no other visible trace of:
99
94
  *
100
- * - WHICH provider is active. The install has no other visible trace of it —
101
- * the sentinel is a zero-byte dotfile and the mechanics are a directory the
102
- * user has no reason to list. `(default)` distinguishes "github because I
103
- * chose it" from "github because nothing was chosen"; `(was jira)` is what
104
- * makes a self-heal or a `--reset` collapse legible rather than silent.
105
- * - WHAT the provider change moved. A provider swap installs one tree and
106
- * prunes another, and the agent file appears or disappears with it.
95
+ * - WHICH provider is the machine default. The sentinel is a dotfile and every
96
+ * provider's mechanics are installed (D-INSTALL-ALL-PROVIDERS), so nothing on
97
+ * disk says which one the machine selected. `(default)` distinguishes "github
98
+ * because I chose it" from "github because nothing was chosen"; `(was jira)`
99
+ * is what makes a self-heal or a `--reset` collapse legible rather than silent.
100
+ * - WHAT this run moved: references written or pruned, and the Tracker agent
101
+ * when this run wrote it.
107
102
  *
108
103
  * The delta line is emitted only when something moved: on a steady-state re-init
109
104
  * the counts are noise.
@@ -10,7 +10,7 @@ import { handleToggle } from './toggle.js';
10
10
  import { handleList } from './list.js';
11
11
  export const knowledgeCommand = new Command('knowledge')
12
12
  .description('Manage per-feature knowledge bases')
13
- .option('--enable', 'Enable per-feature knowledge bases in every project')
13
+ .option('--enable', 'Enable per-feature knowledge bases in every project (a repository can opt out)')
14
14
  .option('--disable', 'Disable per-feature knowledge bases in every project')
15
15
  .option('--status', 'Show knowledge base feature status')
16
16
  .action(async (options) => {
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Handle the enable/disable/status toggle actions for `devflow knowledge`.
3
3
  *
4
- * D-FEATURES-MACHINE-WIDE (src/core/feature-switch.ts): knowledge write-back is
4
+ * D-FEATURES-NARROW-ONLY (src/core/feature-switch.ts): knowledge write-back is
5
5
  * switched for the whole machine by `features.knowledge` in
6
6
  * ~/.devflow/manifest.json. `--enable`/`--disable` write that value — the same
7
7
  * one `devflow init --knowledge / --no-knowledge` writes — and `--status`
8
- * reports it.
8
+ * reports it, plus the repository's narrowing when a repo layer narrows it.
9
9
  */
10
10
  import { promises as fs } from 'fs';
11
11
  import * as path from 'path';
@@ -14,6 +14,7 @@ import color from 'picocolors';
14
14
  import { getGitRoot } from '../../../core/git.js';
15
15
  import { getDevFlowDirectory } from '../../../targets/claude-code/claude-paths.js';
16
16
  import { readMachineFeature, writeMachineFeature } from '../../../core/feature-switch.js';
17
+ import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../../core/evidence-policy.js';
17
18
  import { getFeaturesDir } from '../../../core/project-paths.js';
18
19
  async function getWorktreePath() {
19
20
  return (await getGitRoot()) ?? process.cwd();
@@ -48,6 +49,13 @@ export async function handleToggle(options) {
48
49
  const enabled = await readMachineFeature(devflowDir, 'knowledge');
49
50
  const kbCount = await countKnowledgeBases(await getWorktreePath());
50
51
  p.log.info(`Status: ${enabled ? color.green('enabled') : color.yellow('disabled')}`);
52
+ const settingsModule = loadSettingsModule();
53
+ const narrowed = enabled ? narrowedSwitchLabel(settingsModule, { dir: process.cwd() }, 'knowledge') : null;
54
+ if (narrowed !== null)
55
+ p.log.info(`Effective here: ${color.yellow(narrowed)}`);
56
+ const trackedWarning = personalConfigTrackedWarning(settingsModule, { dir: process.cwd() });
57
+ if (trackedWarning !== null)
58
+ p.log.warn(trackedWarning);
51
59
  p.log.info(`Knowledge bases: ${kbCount}`);
52
60
  p.outro('');
53
61
  return;
@@ -62,7 +70,7 @@ export async function handleToggle(options) {
62
70
  return;
63
71
  }
64
72
  if (enabled) {
65
- p.log.success('Feature knowledge bases enabled in every project');
73
+ p.log.success('Feature knowledge bases enabled in every project (a repository can opt out)');
66
74
  p.log.info('Knowledge bases are created automatically when workflows detect documented area changes.');
67
75
  }
68
76
  else {
@@ -5,8 +5,9 @@ import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getLearningDir, getLearningTuningConfigPath, getDecisionsLogPath, getDecisionsLockDir, } from '../../core/project-paths.js';
7
7
  import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
8
+ import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
8
9
  import { getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
9
- import { getGitRoot } from '../../core/git.js';
10
+ import { getLedgerRoot } from '../../core/ledger-root.js';
10
11
  import { sweepLegacyDreamMarkers, drainLearningQueue } from '../../core/learning-queue-cleanup.js';
11
12
  import { readObservations, warnIfInvalid, } from '../../core/observation-io.js';
12
13
  // ---------------------------------------------------------------------------
@@ -14,7 +15,7 @@ import { readObservations, warnIfInvalid, } from '../../core/observation-io.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  function printUsage() {
16
17
  p.intro(color.bgCyan(color.black(' Learning ')));
17
- p.note(`${color.cyan('devflow learning --enable')} Enable learning in every project\n` +
18
+ p.note(`${color.cyan('devflow learning --enable')} Enable learning in every project (a repository can opt out)\n` +
18
19
  `${color.cyan('devflow learning --disable')} Disable learning in every project (drains this project's queue)\n` +
19
20
  `${color.cyan('devflow learning --status')} Show learning status\n` +
20
21
  `${color.cyan('devflow learning --list')} Show all observations\n` +
@@ -24,28 +25,39 @@ function printUsage() {
24
25
  p.outro(color.dim('Detects architectural decisions and known pitfalls from your sessions'));
25
26
  }
26
27
  /**
27
- * Resolve the git root for a state-mutating subcommand, warning and
28
+ * Resolve the ledger root for a state-mutating subcommand, warning and
28
29
  * returning null if the caller isn't inside a git project. `actionSuffix`
29
30
  * completes "Could not resolve git root — {actionSuffix}".
31
+ *
32
+ * D-LEDGER-MAIN-WORKTREE: every subcommand here resolves the ledger with
33
+ * getLedgerRoot — the hooks' DF_LEDGER_ROOT rule — so in a linked worktree it
34
+ * reads, clears and drains the main checkout's ledger the hooks write.
30
35
  */
31
- async function requireGitRoot(actionSuffix) {
32
- const gitRoot = await getGitRoot();
33
- if (!gitRoot) {
36
+ async function requireLedgerRoot(actionSuffix) {
37
+ const ledgerRoot = await getLedgerRoot();
38
+ if (!ledgerRoot) {
34
39
  p.log.warn(`Could not resolve git root — ${actionSuffix}`);
35
40
  }
36
- return gitRoot;
41
+ return ledgerRoot;
37
42
  }
38
43
  async function handleStatus() {
39
- // D-FEATURES-MACHINE-WIDE: the one switch is the manifest's, so the state is
40
- // the same from every directory; only the observation counts are per-project.
44
+ // D-FEATURES-NARROW-ONLY: the machine switch is the manifest's and reads the
45
+ // same from every directory; a repository layer can only narrow it, and adds a
46
+ // line only when it does. The observation counts are per-project.
41
47
  const enabled = await readMachineFeature(getDevFlowDirectory(), 'learning');
42
- const stateLine = `Learning: ${enabled ? 'enabled' : 'disabled'}`;
43
- const gitRoot = await getGitRoot();
44
- if (!gitRoot) {
48
+ const settingsModule = loadSettingsModule();
49
+ const narrowed = enabled ? narrowedSwitchLabel(settingsModule, { dir: process.cwd() }, 'learning') : null;
50
+ const stateLine = `Learning: ${enabled ? 'enabled' : 'disabled'}`
51
+ + (narrowed === null ? '' : `\nEffective here: ${narrowed}`);
52
+ const trackedWarning = personalConfigTrackedWarning(settingsModule, { dir: process.cwd() });
53
+ if (trackedWarning !== null)
54
+ p.log.warn(trackedWarning);
55
+ const ledgerRoot = await getLedgerRoot();
56
+ if (!ledgerRoot) {
45
57
  p.log.info(`${stateLine}\nObservations: not in a git project`);
46
58
  return;
47
59
  }
48
- const logPath = getDecisionsLogPath(gitRoot);
60
+ const logPath = getDecisionsLogPath(ledgerRoot);
49
61
  const { observations, invalidCount } = await readObservations(logPath);
50
62
  const decisionObs = observations.filter(o => o.type === 'decision' || o.type === 'pitfall');
51
63
  const decisions = observations.filter(o => o.type === 'decision');
@@ -67,12 +79,12 @@ async function handleStatus() {
67
79
  warnIfInvalid(invalidCount);
68
80
  }
69
81
  async function handleList() {
70
- // Resolve the log from the git root (matches --status, --clear, --reset,
71
- // --disable) so `--list` run from a subdirectory finds the real log
72
- // instead of a nonexistent one under process.cwd(). Falls back to cwd
73
- // when not in a git project, preserving the prior behavior for that case.
74
- const gitRoot = await getGitRoot();
75
- const logPath = getDecisionsLogPath(gitRoot ?? process.cwd());
82
+ // Resolve the log from the ledger root (matches --status, --clear, --reset,
83
+ // --disable) so `--list` run from a subdirectory or a linked worktree finds
84
+ // the real log instead of a nonexistent one under process.cwd(). Falls back
85
+ // to cwd when not in a git project, preserving the prior behavior for that case.
86
+ const ledgerRoot = await getLedgerRoot();
87
+ const logPath = getDecisionsLogPath(ledgerRoot ?? process.cwd());
76
88
  let logExists = true;
77
89
  try {
78
90
  await fs.access(logPath);
@@ -150,19 +162,22 @@ async function handleConfigure() {
150
162
  p.log.success(`Global config written to ${color.dim(path.join(globalDir, 'learning.json'))}`);
151
163
  }
152
164
  else {
153
- const learningDir = getLearningDir(process.cwd());
154
- await fs.mkdir(learningDir, { recursive: true });
155
- const projectConfigPath = getLearningTuningConfigPath(process.cwd());
165
+ // D-LEDGER-MAIN-WORKTREE: session-start-context reads the project tuning config
166
+ // from the ledger ($LEDGER_ROOT/.devflow/learning/), so write it there — the
167
+ // main checkout in a linked worktree; the current directory outside git.
168
+ const projectRoot = (await getLedgerRoot()) ?? process.cwd();
169
+ await fs.mkdir(getLearningDir(projectRoot), { recursive: true });
170
+ const projectConfigPath = getLearningTuningConfigPath(projectRoot);
156
171
  await fs.writeFile(projectConfigPath, configJson, 'utf-8');
157
172
  p.log.success(`Project config written to ${color.dim(projectConfigPath)}`);
158
173
  }
159
174
  p.outro(color.green('Configuration saved.'));
160
175
  }
161
176
  async function handleReset() {
162
- const gitRoot = await requireGitRoot('reset not performed');
163
- if (!gitRoot)
177
+ const ledgerRoot = await requireLedgerRoot('reset not performed');
178
+ if (!ledgerRoot)
164
179
  return;
165
- const lockDir = getDecisionsLockDir(gitRoot);
180
+ const lockDir = getDecisionsLockDir(ledgerRoot);
166
181
  // Ensure the parent directory exists so a second reset (after .devflow/learning/
167
182
  // was already removed) does not fail with ENOENT and emit a false contention error.
168
183
  await fs.mkdir(path.dirname(lockDir), { recursive: true });
@@ -189,13 +204,13 @@ async function handleReset() {
189
204
  // Remove the entire learning directory (contains queue files, content files,
190
205
  // ledger, and tuning config). Single-dir semantics: all learning state lives here.
191
206
  try {
192
- await fs.rm(getLearningDir(gitRoot), { recursive: true, force: true });
207
+ await fs.rm(getLearningDir(ledgerRoot), { recursive: true, force: true });
193
208
  }
194
209
  catch { /* best effort */ }
195
210
  // Clean legacy dream marker-pipeline stamps from old installs.
196
211
  // Best-effort: sweeps the now-absent dir silently (ENOENT-tolerant).
197
212
  try {
198
- await sweepLegacyDreamMarkers(getLearningDir(gitRoot));
213
+ await sweepLegacyDreamMarkers(getLearningDir(ledgerRoot));
199
214
  }
200
215
  catch { /* best effort */ }
201
216
  p.log.success('Reset complete — removed .devflow/learning/ state.');
@@ -208,10 +223,10 @@ async function handleReset() {
208
223
  }
209
224
  }
210
225
  async function handleClear() {
211
- const gitRoot = await requireGitRoot('clear not performed');
212
- if (!gitRoot)
226
+ const ledgerRoot = await requireLedgerRoot('clear not performed');
227
+ if (!ledgerRoot)
213
228
  return;
214
- const decisionsLogPath = getDecisionsLogPath(gitRoot);
229
+ const decisionsLogPath = getDecisionsLogPath(ledgerRoot);
215
230
  try {
216
231
  await fs.access(decisionsLogPath);
217
232
  }
@@ -234,11 +249,11 @@ async function handleClear() {
234
249
  // on the next session — mirrors memory.ts's drain-on-disable behavior for
235
250
  // the sibling memory queue. A mid-run Learning agent whose claimed batch
236
251
  // vanishes aborts without changes — the desired outcome of clearing.
237
- await drainLearningQueue(gitRoot);
252
+ await drainLearningQueue(ledgerRoot);
238
253
  p.log.success('Decisions log cleared.');
239
254
  }
240
255
  /**
241
- * `--enable` / `--disable`: the machine-wide switch (D-FEATURES-MACHINE-WIDE),
256
+ * `--enable` / `--disable`: the machine-wide switch (D-FEATURES-NARROW-ONLY),
242
257
  * converged exactly as `devflow init --learning / --no-learning` converges it —
243
258
  * the manifest value, and on disable a drained queue in the current project.
244
259
  * Never requires a git root: the switch is not a per-project setting.
@@ -251,22 +266,22 @@ async function handleToggle(enabled) {
251
266
  return;
252
267
  }
253
268
  if (enabled) {
254
- p.log.success('Learning enabled in every project');
269
+ p.log.success('Learning enabled in every project (a repository can opt out)');
255
270
  p.log.info(color.dim('Architectural decisions and pitfalls will be detected from your sessions'));
256
271
  return;
257
272
  }
258
273
  // Drain the current project's learning (decisions-detection) queue so stale
259
274
  // turns don't process on re-enable. A mid-run Learning agent whose claimed
260
275
  // batch vanishes aborts without changes — the desired outcome of disabling.
261
- const gitRoot = await getGitRoot();
262
- if (gitRoot) {
263
- await drainLearningQueue(gitRoot);
276
+ const ledgerRoot = await getLedgerRoot();
277
+ if (ledgerRoot) {
278
+ await drainLearningQueue(ledgerRoot);
264
279
  }
265
280
  p.log.success('Learning disabled in every project');
266
281
  }
267
282
  export const learningCommand = new Command('learning')
268
283
  .description('Enable or disable learning (decision/pitfall detection) in every project')
269
- .option('--enable', 'Enable learning in every project')
284
+ .option('--enable', 'Enable learning in every project (a repository can opt out)')
270
285
  .option('--disable', 'Disable learning in every project')
271
286
  .option('--status', 'Show learning status and observation counts')
272
287
  .option('--list', 'Show all decision/pitfall observations sorted by confidence')
@@ -1,3 +1,4 @@
1
+ import { devflowHookOwner, hasHook, removeHooks, } from '../../targets/claude-code/hooks.js';
1
2
  // ─── Dream worker hook cleanup ──────────────────────────────────────────────
2
3
  //
3
4
  // The spawn-dream-worker SessionStart hook belonged to the retired detached
@@ -6,27 +7,23 @@
6
7
  // own. remove/has exist for upgrade cleanup: init and uninstall strip any
7
8
  // stale entry left in settings.json by a prior install.
8
9
  const SPAWN_DREAM_WORKER_MARKER = 'spawn-dream-worker';
10
+ /**
11
+ * D-EXACT-HOOK-OWNER: the dream-worker hook is devflow's only when its command
12
+ * ends in `/scripts/hooks/run-hook spawn-dream-worker` (hooks.ts), under any
13
+ * directory — the one form it was ever registered in.
14
+ */
15
+ const isDreamHook = devflowHookOwner([SPAWN_DREAM_WORKER_MARKER]);
9
16
  /**
10
17
  * Remove the spawn-dream-worker hook from settings JSON.
11
18
  * Idempotent — returns unchanged JSON if hook not present.
12
- * Preserves all other SessionStart hooks (session-start-memory, session-start-context).
19
+ * Removes the single hook, so the other hooks of its matcher group and every
20
+ * other SessionStart group (session-start-memory, session-start-context) stay in place.
13
21
  */
14
22
  export function removeDreamHook(settingsJson) {
15
23
  const settings = JSON.parse(settingsJson);
16
- if (!settings.hooks?.SessionStart) {
17
- return settingsJson;
18
- }
19
- const before = settings.hooks.SessionStart.length;
20
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter((matcher) => !matcher.hooks.some((h) => h.command.includes(SPAWN_DREAM_WORKER_MARKER)));
21
- if (settings.hooks.SessionStart.length === before) {
24
+ if (!removeHooks(settings, 'SessionStart', isDreamHook)) {
22
25
  return settingsJson;
23
26
  }
24
- if (settings.hooks.SessionStart.length === 0) {
25
- delete settings.hooks.SessionStart;
26
- }
27
- if (Object.keys(settings.hooks).length === 0) {
28
- delete settings.hooks;
29
- }
30
27
  return JSON.stringify(settings, null, 2) + '\n';
31
28
  }
32
29
  /**
@@ -35,6 +32,6 @@ export function removeDreamHook(settingsJson) {
35
32
  */
36
33
  export function hasDreamHook(input) {
37
34
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
38
- return settings.hooks?.SessionStart?.some((matcher) => matcher.hooks.some((h) => h.command.includes(SPAWN_DREAM_WORKER_MARKER))) ?? false;
35
+ return hasHook(settings, 'SessionStart', isDreamHook);
39
36
  }
40
37
  //# sourceMappingURL=legacy-hooks.js.map
@@ -4,11 +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 { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
7
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
8
8
  import { discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
9
9
  import { getGitRoot } from '../../core/git.js';
10
10
  import { getMemoryDir, getPendingTurnsPath, getPendingTurnsProcessingPath, } from '../../core/project-paths.js';
11
+ import { HOOKS_DIR_SUFFIX, devflowHookOwner, endsWithAny, ensureHook, hasHook, removeHooks, runHookCommand, runHookSuffix, } from '../../targets/claude-code/hooks.js';
11
12
  import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
13
+ import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
12
14
  /**
13
15
  * Map of hook event type → filename marker for the memory hooks.
14
16
  * Three hooks total: Stop, SessionStart, PreCompact.
@@ -18,10 +20,11 @@ import { readMachineFeature, writeMachineFeature } from '../../core/feature-swit
18
20
  * at the hook-registration level), and decisions detection is a SessionStart-spawned
19
21
  * detached worker rather than a SessionEnd hook (see legacy-hooks.ts).
20
22
  *
21
- * Stop-array ordering contract: memory-worker MUST be registered AFTER capture-turn
22
- * in the Stop hook array (append-before-spawn — memory-worker's throttle/spawn
23
- * decision assumes the current turn was already appended by capture-turn earlier
24
- * 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.
25
28
  */
26
29
  const MEMORY_HOOK_CONFIG = {
27
30
  Stop: 'memory-worker',
@@ -29,14 +32,32 @@ const MEMORY_HOOK_CONFIG = {
29
32
  PreCompact: 'pre-compact-memory',
30
33
  };
31
34
  /**
32
- * Legacy hook filename markers from prior architectures.
33
- * 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.
34
43
  */
35
- const LEGACY_HOOK_MARKERS = {
36
- UserPromptSubmit: ['prompt-capture-memory', 'sidecar-dispatch', 'dream-dispatch'],
37
- Stop: ['stop-update-memory', 'stop-update-learning', 'sidecar-capture', 'dream-capture'],
38
- 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),
39
56
  };
57
+ /** D-EXACT-HOOK-OWNER: the current memory hook for `marker` (hooks.ts). */
58
+ function isMemoryHook(marker) {
59
+ return devflowHookOwner([marker]);
60
+ }
40
61
  /**
41
62
  * Add all 3 memory hooks (Stop, SessionStart, PreCompact) to settings JSON.
42
63
  * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 3 present.
@@ -46,28 +67,10 @@ export function addMemoryHooks(settingsJson, devflowDir) {
46
67
  if (hasMemoryHooks(settings)) {
47
68
  return settingsJson;
48
69
  }
49
- if (!settings.hooks) {
50
- settings.hooks = {};
51
- }
52
70
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
53
- const existing = settings.hooks[hookType] ?? [];
54
- const alreadyPresent = existing.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)));
55
- if (!alreadyPresent) {
56
- const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${marker}`;
57
- const newEntry = {
58
- hooks: [
59
- {
60
- type: 'command',
61
- command: hookCommand,
62
- timeout: 10,
63
- },
64
- ],
65
- };
66
- if (!settings.hooks[hookType]) {
67
- settings.hooks[hookType] = [];
68
- }
69
- settings.hooks[hookType].push(newEntry);
70
- }
71
+ ensureHook(settings, hookType, isMemoryHook(marker), {
72
+ hooks: [{ type: 'command', command: runHookCommand(devflowDir, marker), timeout: 10 }],
73
+ });
71
74
  }
72
75
  return JSON.stringify(settings, null, 2) + '\n';
73
76
  }
@@ -80,36 +83,16 @@ export function addMemoryHooks(settingsJson, devflowDir) {
80
83
  export function removeMemoryHooks(input) {
81
84
  const settingsJson = typeof input === 'string' ? input : JSON.stringify(input);
82
85
  const settings = typeof input === 'string' ? JSON.parse(input) : structuredClone(input);
83
- if (!settings.hooks) {
84
- return settingsJson;
85
- }
86
+ // Evaluate every removal into a local — never short-circuit (PF-015).
86
87
  let changed = false;
87
88
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
88
- if (!settings.hooks[hookType]) {
89
- continue;
90
- }
91
- const before = settings.hooks[hookType].length;
92
- settings.hooks[hookType] = settings.hooks[hookType].filter((matcher) => !matcher.hooks.some((h) => h.command.includes(marker)));
93
- if (settings.hooks[hookType].length !== before) {
94
- changed = true;
95
- }
96
- if (settings.hooks[hookType].length === 0) {
97
- delete settings.hooks[hookType];
98
- }
99
- }
100
- // Remove legacy pre-dream hooks from upgrading users
101
- for (const [hookType, markers] of Object.entries(LEGACY_HOOK_MARKERS)) {
102
- if (!settings.hooks[hookType])
103
- continue;
104
- const before = settings.hooks[hookType].length;
105
- settings.hooks[hookType] = settings.hooks[hookType].filter((matcher) => !matcher.hooks.some((h) => markers.some((m) => h.command.includes(m))));
106
- if (settings.hooks[hookType].length !== before)
107
- changed = true;
108
- if (settings.hooks[hookType].length === 0)
109
- delete settings.hooks[hookType];
89
+ const removed = removeHooks(settings, hookType, isMemoryHook(marker));
90
+ changed = changed || removed;
110
91
  }
111
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
112
- 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;
113
96
  }
114
97
  if (!changed) {
115
98
  return settingsJson;
@@ -128,32 +111,29 @@ export function hasMemoryHooks(input) {
128
111
  */
129
112
  export function countMemoryHooks(input) {
130
113
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
131
- if (!settings.hooks) {
132
- return 0;
133
- }
134
114
  let count = 0;
135
115
  for (const [hookType, marker] of Object.entries(MEMORY_HOOK_CONFIG)) {
136
- const matchers = settings.hooks[hookType] ?? [];
137
- if (matchers.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)))) {
116
+ if (hasHook(settings, hookType, isMemoryHook(marker)))
138
117
  count++;
139
- }
140
118
  }
141
119
  return count;
142
120
  }
143
121
  /**
144
122
  * Converge the memory hooks in a settings JSON string to `enabled`. Pure.
145
123
  *
146
- * D-FEATURES-MACHINE-WIDE: the ONE settings transform for the memory feature,
124
+ * D-FEATURES-NARROW-ONLY: the ONE settings transform for the memory feature,
147
125
  * shared by `devflow init` (inside its single settings read-modify-write pass)
148
126
  * and `devflow memory --enable/--disable`, so the two controls of the same
149
127
  * machine-wide switch leave settings.json byte-for-byte alike. Always
150
128
  * remove-then-add, which also upgrades an older hook format (e.g. `.sh` →
151
129
  * `run-hook`) in place.
152
130
  *
153
- * Stop-array ordering (AC-C2): memory-worker is appended after whatever the
154
- * Stop array already holds, so it lands after capture-turn as long as the
155
- * capture hooks are registered first — init registers them earlier in the same
156
- * pass, and on a standalone toggle they are already present.
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.
157
137
  */
158
138
  export function convergeMemoryHooks(settingsJson, enabled, devflowDir) {
159
139
  const cleaned = removeMemoryHooks(settingsJson);
@@ -227,7 +207,7 @@ export async function cleanQueueFiles(projectPaths) {
227
207
  }
228
208
  export const memoryCommand = new Command('memory')
229
209
  .description('Enable, disable, or clean up working memory (session context preservation)')
230
- .option('--enable', 'Enable working memory in every project')
210
+ .option('--enable', 'Enable working memory in every project (a repository can opt out)')
231
211
  .option('--disable', 'Disable working memory in every project')
232
212
  .option('--status', 'Show current state')
233
213
  .option('--clear', 'Clean up queue files from projects')
@@ -235,7 +215,7 @@ export const memoryCommand = new Command('memory')
235
215
  const hasFlag = options.enable || options.disable || options.status || options.clear;
236
216
  if (!hasFlag) {
237
217
  p.intro(color.bgCyan(color.white(' Working Memory ')));
238
- p.note(`${color.cyan('devflow memory --enable')} Enable working memory (every project)\n` +
218
+ p.note(`${color.cyan('devflow memory --enable')} Enable working memory (every project; a repository can opt out)\n` +
239
219
  `${color.cyan('devflow memory --disable')} Disable working memory (every project)\n` +
240
220
  `${color.cyan('devflow memory --status')} Check current state\n` +
241
221
  `${color.cyan('devflow memory --clear')} Clean up queue files`, 'Usage');
@@ -245,7 +225,7 @@ export const memoryCommand = new Command('memory')
245
225
  if (options.clear) {
246
226
  p.intro(color.bgCyan(color.white(' Memory Cleanup ')));
247
227
  // Discover current project and all known projects in parallel
248
- const [gitRoots, gitRoot] = await Promise.all([discoverProjectGitRoots(), getGitRoot()]);
228
+ const [gitRoots, gitRoot] = await Promise.all([discoverProjectGitRoots(getClaudeDirectory()), getGitRoot()]);
249
229
  const [projectsWithMemory, currentProjectHasMem] = await Promise.all([
250
230
  filterProjectsWithMemory(gitRoots),
251
231
  gitRoot ? hasMemoryDir(gitRoot) : Promise.resolve(false),
@@ -302,8 +282,10 @@ export const memoryCommand = new Command('memory')
302
282
  settingsContent = '{}';
303
283
  }
304
284
  if (options.status) {
305
- // D-FEATURES-MACHINE-WIDE: one switch, the manifest's. The hook count is
306
- // reported beside it because the hooks are how that switch takes effect.
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.
307
289
  const enabled = await readMachineFeature(devflowDir, 'memory');
308
290
  const count = countMemoryHooks(settingsContent);
309
291
  const total = Object.keys(MEMORY_HOOK_CONFIG).length;
@@ -317,10 +299,17 @@ export const memoryCommand = new Command('memory')
317
299
  p.log.info(`Working memory: ${color.yellow(`enabled, but ${count}/${total} hooks registered`)} — ` +
318
300
  `run ${color.cyan('devflow memory --enable')} to fix`);
319
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);
320
309
  return;
321
310
  }
322
311
  // --enable / --disable: the machine-wide switch, converged exactly as
323
- // `devflow init --memory / --no-memory` converges it (D-FEATURES-MACHINE-WIDE).
312
+ // `devflow init --memory / --no-memory` converges it (D-FEATURES-NARROW-ONLY).
324
313
  // The settings transform runs FIRST: it is the step that can reject its
325
314
  // input (malformed JSON), and the switch must not be recorded unless the
326
315
  // hooks that enact it can follow.
@@ -341,10 +330,10 @@ export const memoryCommand = new Command('memory')
341
330
  return;
342
331
  }
343
332
  if (converged !== settingsContent) {
344
- await writeFileAtomicExclusive(settingsPath, converged);
333
+ await writeSettingsFileAtomic(settingsPath, converged);
345
334
  }
346
335
  if (enabled) {
347
- p.log.success('Working memory enabled in every project');
336
+ p.log.success('Working memory enabled in every project (a repository can opt out)');
348
337
  p.log.info(color.dim('Session context will be automatically preserved across conversations'));
349
338
  return;
350
339
  }