devflow-kit 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -1,125 +1,260 @@
1
1
  import * as path from 'path';
2
2
  import { promises as fs } from 'fs';
3
3
  import { getFeatureConfigPath } from './project-paths.js';
4
+ import { parseTrackerId } from './tracker.js';
5
+ import { loadProjectConfigLib } from './evidence-policy.js';
6
+ /**
7
+ * Keys devflow itself once wrote and has retired. A managed write drops them
8
+ * rather than carrying them; no reader consults them.
9
+ *
10
+ * D-FEATURES-NARROW-ONLY: `memory`, `learning` and `knowledge` were top-level
11
+ * per-repo feature switches from the per-repo-install era. A repository now
12
+ * narrows a feature only through the `features` namespace, so a stale top-level
13
+ * value must neither decide anything nor linger to be mistaken for a switch:
14
+ * carrying it would leave a `learning: false` in the file that no longer does
15
+ * what it says. `decisions` is the pre-rename spelling of `learning`;
16
+ * `autoCommit` is inert. `features` is deliberately NOT here — it is a live key,
17
+ * carried like any other unmanaged key (avoids PF-071).
18
+ */
19
+ const RETIRED_CONFIG_KEYS = new Set([
20
+ 'memory', 'learning', 'knowledge', 'decisions', 'autoCommit',
21
+ ]);
4
22
  export const DEFAULT_CONFIG = {
5
- memory: true,
6
- learning: true,
7
- knowledge: true,
8
23
  reviewPublication: 'auto',
9
24
  };
10
25
  export function getConfigPath(projectRoot) {
11
26
  return getFeatureConfigPath(projectRoot);
12
27
  }
28
+ /**
29
+ * Parse the per-repo tracker override from the raw config value.
30
+ *
31
+ * Pure. Delegates membership to `parseTrackerId` rather than re-spelling a
32
+ * closed-domain ternary the way `reviewPublication` does: `reviewPublication`
33
+ * self-heals any invalid value to `'auto'`, which is correct for a publication
34
+ * mode and wrong for a provider — a repaired provider is the laundering path
35
+ * GAP-10 names, and §14.2 gives the invalid case its own DEGRADED reason, which
36
+ * only exists if the parse REFUSES instead of healing.
37
+ *
38
+ * An empty string reads as absent: `"tracker": ""` is an unset key with a
39
+ * character in it, not an attempt to name a provider.
40
+ *
41
+ * A non-string JSON value (`42`, `true`, `null`, an array, an object) is
42
+ * `invalid`, not `absent` — a present-but-wrong-typed value means the file was
43
+ * edited, and reporting it as absent would make the whole class silent.
44
+ */
45
+ export function parseTrackerOverride(raw) {
46
+ if (raw === undefined || raw === '')
47
+ return { kind: 'absent' };
48
+ // `unknown` all the way from the field: this is a boundary parse over
49
+ // hand-edited JSON, and a signature that promised a string would make the
50
+ // wrong-type arm unreachable to the compiler while it stays entirely reachable
51
+ // to a user with a text editor.
52
+ if (typeof raw !== 'string') {
53
+ return { kind: 'invalid', raw: JSON.stringify(raw) ?? String(raw) };
54
+ }
55
+ const parsed = parseTrackerId(raw);
56
+ return parsed.ok ? { kind: 'valid', provider: parsed.value } : { kind: 'invalid', raw };
57
+ }
58
+ /**
59
+ * Whether a parsed JSON value is an object a config can be read from — the one
60
+ * test for "the file holds a config", shared by the reader and the managed
61
+ * write so the two never disagree about which files count as empty.
62
+ */
63
+ function isJsonObject(value) {
64
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
65
+ }
13
66
  /**
14
67
  * Parse and narrow an unknown JSON value into a FeatureConfig, merging onto
15
68
  * DEFAULT_CONFIG. Pure function — no I/O, no side effects.
16
69
  *
17
- * Coalesces legacy `decisions` key into `learning` when both are present:
18
- * `decisions` wins (legacy-decisions-wins semantics; intentionally opposite to
19
- * manifest.ts's new-key-wins self-heal — migration-compat requires the old key
20
- * to take precedence so old configs with `decisions: false` are not silently
21
- * re-enabled by a newer `learning: true` key).
22
- * Silently ignores `autoCommit` — old configs may still contain it.
70
+ * The retired keys ({@link RETIRED_CONFIG_KEYS}) are ignored: an old config may
71
+ * still hold them, and none of them decides anything (D-FEATURES-NARROW-ONLY).
23
72
  *
24
73
  * Returns null when `parsed` is not a plain object (caller falls through to
25
74
  * the next candidate path).
26
75
  */
27
76
  function coerceConfig(parsed) {
28
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
77
+ if (!isJsonObject(parsed))
29
78
  return null;
30
79
  const p = parsed;
31
- // Coalesce decisions (legacy key) → learning. decisions wins when both present.
32
- let learning = DEFAULT_CONFIG.learning;
33
- if (typeof p.learning === 'boolean')
34
- learning = p.learning;
35
- if (typeof p.decisions === 'boolean')
36
- learning = p.decisions; // decisions wins
37
80
  // Coerce reviewPublication: any invalid or absent value → 'auto' (self-heal, ADR-014 idiom).
38
81
  const rp = p.reviewPublication;
39
82
  const reviewPublication = rp === 'auto' || rp === 'full' || rp === 'off' ? rp : 'auto';
83
+ // The per-repo tracker override is carried through VERBATIM — never coerced,
84
+ // never type-filtered. Two reasons, and the second is the one a reader is
85
+ // likely to miss:
86
+ // (a) repair is forbidden for a provider value (§14.9-6), so there is no
87
+ // healed value to fall back to the way reviewPublication has 'auto';
88
+ // (b) a non-string is a state parseTrackerOverride CLASSIFIES (`invalid`),
89
+ // not a state this function repairs. Filtering by type here would leave
90
+ // that arm reachable from a direct call to the parser and unreachable
91
+ // from the file, which is the only place it can actually be written.
92
+ // Key PRESENCE is the whole rule: a present key is carried as written, and an
93
+ // absent one stays absent. `hasOwnProperty` rather than `in` because the
94
+ // object comes from JSON.parse at a trust boundary. Retention on disk is not
95
+ // decided here: the writers carry every unmanaged key from the file itself
96
+ // (D-CONFIG-PRESERVE-UNMANAGED).
97
+ const hasTracker = Object.prototype.hasOwnProperty.call(p, 'tracker');
40
98
  return {
41
- memory: typeof p.memory === 'boolean' ? p.memory : DEFAULT_CONFIG.memory,
42
- learning,
43
- knowledge: typeof p.knowledge === 'boolean' ? p.knowledge : DEFAULT_CONFIG.knowledge,
44
99
  reviewPublication,
100
+ ...(hasTracker ? { tracker: p.tracker } : {}),
45
101
  };
46
102
  }
47
103
  /**
48
- * Read the feature config for a project root.
49
- * Returns DEFAULT_CONFIG when the file is missing or unreadable.
50
- * Applies ADR-001: .devflow/config.json is the sole source of truth;
51
- * `devflow init` writes it directly on first install. An absent file falls
52
- * through to DEFAULT_CONFIG (all features enabled by default).
104
+ * Read the per-repo config for a project root.
105
+ * Returns DEFAULT_CONFIG when the file is missing or unusable.
53
106
  */
54
107
  export async function readConfig(projectRoot) {
55
- const configPath = getFeatureConfigPath(projectRoot);
108
+ return coerceConfig(objectOf(await readConfigBody(projectRoot))) ?? { ...DEFAULT_CONFIG };
109
+ }
110
+ /**
111
+ * Classify a config file's bytes (`null` is no file). Pure.
112
+ *
113
+ * D-CONFIG-STRICT-PARSE: `.devflow/config.json` is judged by the one parser the
114
+ * resolvers use (lib/project-config.cjs, D-PROJECT-STRICT-KEYS), never by a bare
115
+ * `JSON.parse`. The two disagreed where it mattered: `JSON.parse` keeps the
116
+ * LAST of two duplicate keys silently, while the resolvers read the file as
117
+ * saying two things and fail its keys closed — so devflow could rewrite a file
118
+ * into a meaning it never had. A file the parser rejects is `malformed`.
119
+ */
120
+ export function classifyConfigBytes(buf, lib) {
121
+ const decoded = lib.decodeConfigBytes(buf);
122
+ if (decoded.kind === 'absent')
123
+ return { kind: 'absent' };
124
+ if (decoded.kind === 'invalid')
125
+ return { kind: 'malformed' };
126
+ let parsed;
56
127
  try {
57
- const config = coerceConfig(JSON.parse(await fs.readFile(configPath, 'utf-8')));
58
- if (config !== null)
59
- return config;
60
- return { ...DEFAULT_CONFIG };
128
+ parsed = JSON.parse(decoded.text);
61
129
  }
62
130
  catch {
63
- return { ...DEFAULT_CONFIG };
131
+ return { kind: 'malformed' };
64
132
  }
133
+ if (!isJsonObject(parsed))
134
+ return { kind: 'malformed' };
135
+ const duplicates = lib.collectDuplicateKeyPaths(decoded.text);
136
+ if (duplicates === null || duplicates.size > 0)
137
+ return { kind: 'malformed' };
138
+ return { kind: 'object', value: parsed };
139
+ }
140
+ /** The object a config body holds, or undefined for any other body. */
141
+ function objectOf(body) {
142
+ return body.kind === 'object' ? body.value : undefined;
65
143
  }
66
144
  /**
67
- * Write the feature config for a project root.
145
+ * Read and classify a project's config file. Every read of the file goes
146
+ * through here, so readConfig, readConfigIfPresent and writeManagedConfig agree
147
+ * on what an unusable file means. Never throws.
148
+ *
149
+ * D-CONFIG-NO-FOLLOW: the bytes come from the resolvers' own bounded read
150
+ * (lib/project-config.cjs readBoundedRegularFile, `followSymlinks` false — the
151
+ * read resolve-settings' readConfigFile makes), so devflow and the settings line
152
+ * never disagree about which file configures the repository. A symlink —
153
+ * dangling or not — a directory, a FIFO or a file over MAX_CONFIG_BYTES is
154
+ * refused unopened and reads as `unreadable`: readers configure nothing from it,
155
+ * and writeManagedConfig leaves it in place (D-CONFIG-NO-REPAIR) rather than
156
+ * writing through the link or renaming a regular file over it.
157
+ */
158
+ async function readConfigBody(projectRoot, lib = loadProjectConfigLib()) {
159
+ if (!lib.ok)
160
+ return { kind: 'unreadable', detail: `config parser unavailable: ${lib.error.path}` };
161
+ const read = lib.value.readBoundedRegularFile(getFeatureConfigPath(projectRoot), lib.value.MAX_CONFIG_BYTES, false);
162
+ if (read.kind === 'absent')
163
+ return { kind: 'absent' };
164
+ if (read.kind === 'refused') {
165
+ return {
166
+ kind: 'unreadable',
167
+ detail: `not a regular file of at most ${lib.value.MAX_CONFIG_BYTES} bytes; a symlink is never followed`,
168
+ };
169
+ }
170
+ return classifyConfigBytes(read.bytes, lib.value);
171
+ }
172
+ /**
173
+ * Serialise a config body to a project's config file.
68
174
  * Creates the .devflow/ directory if missing.
69
175
  * Uses an atomic temp+rename pattern to prevent partial reads under concurrent writes.
70
176
  */
71
- export async function writeConfig(projectRoot, config) {
177
+ async function writeConfigBody(projectRoot, body) {
72
178
  const configPath = getFeatureConfigPath(projectRoot);
73
179
  await fs.mkdir(path.join(projectRoot, '.devflow'), { recursive: true });
74
180
  const tmpPath = configPath + '.tmp.' + process.pid;
75
- await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
181
+ await fs.writeFile(tmpPath, JSON.stringify(body, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
76
182
  await fs.rename(tmpPath, configPath);
77
183
  }
78
184
  /**
79
- * Toggle a single feature in the feature config.
80
- * Reads current config, applies the change, and writes back.
185
+ * Merge devflow's managed keys over the config body the file already holds.
186
+ * Pure — returns a new object and never mutates `existing`.
81
187
  *
82
- * D1: Non-atomic read-modify-write. Concurrent invocations of `updateFeature`
83
- * could lose each other's writes. Acceptable here because: (a) devflow CLI
84
- * commands are single-threaded user-initiated actions, and (b) the window is
85
- * milliseconds on a local filesystem with no concurrent writers in normal use.
86
- * If concurrent safety is ever required, replace with an atomic file-swap or
87
- * a lock file.
188
+ * D-CONFIG-PRESERVE-UNMANAGED (avoids PF-071): `.devflow/config.json` is a
189
+ * user-editable file that devflow only PARTLY owns. The managed keys come from
190
+ * `managed`; every other key comes from the file, verbatim and by key presence
191
+ * — the hand-written per-repo `tracker` override (whose invalid values must
192
+ * survive so their DEGRADED report can name them) and any key devflow does not
193
+ * know. The alternative, writing the declared shape, is a silent delete of all
194
+ * of them on every `devflow init`. Every writer of the file goes through here
195
+ * (writeManagedConfig, called by init). Only the managed key is copied from
196
+ * `managed`, by name, so a caller holding a whole FeatureConfig still cannot
197
+ * overwrite the file's override with its in-memory copy. The retired keys in
198
+ * RETIRED_CONFIG_KEYS are dropped, not carried. A body that is
199
+ * not a JSON object reads as empty, exactly as readConfigIfPresent treats it;
200
+ * writeManagedConfig never reaches here with a malformed file.
201
+ *
202
+ * This holds under `devflow init --reset` too: a factory reset returns
203
+ * devflow's own settings to their defaults through `managed`, and leaves the
204
+ * keys devflow never wrote alone.
88
205
  */
89
- export async function updateFeature(projectRoot, feature, enabled) {
90
- const config = await readConfig(projectRoot);
91
- await writeConfig(projectRoot, { ...config, [feature]: enabled });
206
+ export function mergeManagedConfig(existing, managed) {
207
+ const fileKeys = isJsonObject(existing)
208
+ ? Object.fromEntries(Object.entries(existing).filter(([key]) => !RETIRED_CONFIG_KEYS.has(key)))
209
+ : {};
210
+ return {
211
+ ...fileKeys,
212
+ reviewPublication: managed.reviewPublication,
213
+ };
92
214
  }
93
215
  /**
94
- * Check whether a specific feature is enabled for the given project root.
216
+ * Write devflow's managed keys to a project's config, keeping every key it
217
+ * does not manage (D-CONFIG-PRESERVE-UNMANAGED). Never throws.
218
+ *
219
+ * D-CONFIG-NO-REPAIR: a file that exists but is malformed or unreadable
220
+ * (D-CONFIG-STRICT-PARSE) is left byte-for-byte as it is, and the Result says
221
+ * why. The file is the user's: a syntax error in it still holds their
222
+ * hand-written keys — the `tracker` override first among them — and a rewrite
223
+ * from an empty merge would delete them silently. The resolvers fail its keys
224
+ * closed meanwhile, so leaving it costs nothing but the managed key.
225
+ *
226
+ * D1: Non-atomic read-modify-write. A concurrent writer could lose the other's
227
+ * change. Acceptable because init is a single-threaded, user-initiated command
228
+ * and the window is milliseconds on a local filesystem; the file swap itself is
229
+ * atomic (temp + rename), so a reader never sees a partial file.
95
230
  */
96
- export async function isFeatureEnabled(projectRoot, feature) {
97
- const config = await readConfig(projectRoot);
98
- return config[feature];
231
+ export async function writeManagedConfig(projectRoot, managed, lib = loadProjectConfigLib()) {
232
+ const configPath = getFeatureConfigPath(projectRoot);
233
+ const existing = await readConfigBody(projectRoot, lib);
234
+ if (existing.kind === 'malformed')
235
+ return { ok: false, error: { kind: 'malformed', path: configPath } };
236
+ if (existing.kind === 'unreadable') {
237
+ return { ok: false, error: { kind: 'unreadable', path: configPath, detail: existing.detail } };
238
+ }
239
+ try {
240
+ await writeConfigBody(projectRoot, mergeManagedConfig(objectOf(existing), managed));
241
+ }
242
+ catch (err) {
243
+ return { ok: false, error: { kind: 'write-failed', path: configPath, detail: err instanceof Error ? err.message : String(err) } };
244
+ }
245
+ return { ok: true };
99
246
  }
100
247
  /**
101
- * Read the feature config for a project root, returning null when the file
248
+ * Read the per-repo config for a project root, returning null when the file
102
249
  * is absent or malformed.
103
250
  *
104
251
  * Unlike readConfig (which falls back to DEFAULT_CONFIG on any error),
105
252
  * readConfigIfPresent distinguishes "not configured yet" (null) from
106
- * "configured with specific values" (FeatureConfig). The distinction
107
- * matters for init-seed resolution: a present config overrides the
108
- * manifest for memory/learning/knowledge even when the manifest is absent.
109
- *
110
- * Applies ADR-001: .devflow/config.json is the source of truth; null means
111
- * the config file does not exist or is unreadable — not that all features
112
- * are disabled.
253
+ * "configured with specific values" (FeatureConfig). init relies on the
254
+ * distinction to carry a repo's own reviewPublication across a re-init.
113
255
  */
114
256
  export async function readConfigIfPresent(projectRoot) {
115
- const configPath = getFeatureConfigPath(projectRoot);
116
- try {
117
- const text = await fs.readFile(configPath, 'utf-8');
118
- return coerceConfig(JSON.parse(text)); // null when JSON is not a plain object
119
- }
120
- catch {
121
- // ENOENT (absent) or SyntaxError (malformed) — treat as not present
122
- return null;
123
- }
257
+ // null when the file is absent, malformed or unreadable
258
+ return coerceConfig(objectOf(await readConfigBody(projectRoot)));
124
259
  }
125
260
  //# sourceMappingURL=feature-config.js.map
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ import { writeFileAtomicExclusive } from './fs-atomic.js';
4
+ function isJsonObject(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ /**
8
+ * The pre-rename key each machine feature was stored under, where one exists:
9
+ * `learning` was `decisions` (ADR-011) and `knowledge` was `kb`. A manifest no
10
+ * command has rewritten since the rename can still hold only the legacy key.
11
+ * `memory` was never renamed.
12
+ */
13
+ const LEGACY_KEYS = {
14
+ learning: 'decisions',
15
+ knowledge: 'kb',
16
+ };
17
+ /**
18
+ * Whether a RAW parsed manifest leaves `feature` switched on. Pure.
19
+ *
20
+ * Only an explicit boolean `false` switches a feature off. A missing key, a
21
+ * non-boolean value, or anything that is not a manifest-shaped object reads as
22
+ * ON — fail-open (ADR-028), and the exact rule `queue_read_gates` applies in the
23
+ * shell hooks, so the CLI's status and the runtime never disagree about the
24
+ * same file.
25
+ *
26
+ * D-LEARNING-LEGACY-DECISIONS (a sub-decision of D-FEATURES-NARROW-ONLY):
27
+ * `learning` is read as `features.learning` when that is a boolean, else the
28
+ * legacy `features.decisions` when THAT is a boolean, else ON — readManifest's
29
+ * migration precedence exactly. The legacy key is otherwise honoured only once
30
+ * some command happens to run readManifest and heal it, so a `decisions: false`
31
+ * would keep learning running until then. queue_read_gates applies the same
32
+ * precedence.
33
+ *
34
+ * D-KNOWLEDGE-LEGACY-KB (the same sub-decision for the other renamed key):
35
+ * `knowledge` is read as `features.knowledge` when that is a boolean, else the
36
+ * legacy `features.kb` when THAT is a boolean, else ON — again readManifest's
37
+ * precedence exactly, so `devflow knowledge --status` reports a `kb: false`
38
+ * as disabled. queue_read_gates never reads knowledge, so there is no shell
39
+ * mirror. The knowledge write-back prose gate deliberately does not learn the
40
+ * legacy key (ADR-028: no prompt text for a state only an un-upgraded install
41
+ * can hold); readManifest rewrites `kb` to `knowledge` on the next CLI run
42
+ * that loads the manifest, after which that gate reads the healed key.
43
+ *
44
+ * Deliberately NOT built on readManifest(): it returns null for a manifest
45
+ * missing any of its required fields — reported as "on" here, as the hooks read
46
+ * it — and it writes its heals back to disk, which a read-only status must not.
47
+ */
48
+ export function isMachineFeatureOn(rawManifest, feature) {
49
+ if (!isJsonObject(rawManifest))
50
+ return true;
51
+ const features = rawManifest.features;
52
+ if (!isJsonObject(features))
53
+ return true;
54
+ const legacyKey = LEGACY_KEYS[feature];
55
+ const value = legacyKey !== undefined && typeof features[feature] !== 'boolean'
56
+ ? features[legacyKey]
57
+ : features[feature];
58
+ return value !== false;
59
+ }
60
+ /**
61
+ * Set `feature` in a RAW parsed manifest. Pure — returns a new object and never
62
+ * mutates its input. Null when the value is not a manifest-shaped object (there
63
+ * is no `features` record to write into).
64
+ *
65
+ * Only `features.<feature>` and `updatedAt` change; every other key is carried
66
+ * verbatim. Writing `learning` leaves a legacy `decisions` key in place, and
67
+ * writing `knowledge` a legacy `kb` key, inert: the boolean just written wins
68
+ * over it (D-LEARNING-LEGACY-DECISIONS, D-KNOWLEDGE-LEGACY-KB). Going through
69
+ * readManifest()/writeManifest() instead would refuse a manifest that reader
70
+ * rejects, drop every key ManifestData does not model (one a newer devflow
71
+ * wrote, say), and persist that reader's unrelated heals as a side effect of a
72
+ * one-key toggle.
73
+ */
74
+ export function setMachineFeature(rawManifest, feature, enabled, now) {
75
+ if (!isJsonObject(rawManifest) || !isJsonObject(rawManifest.features))
76
+ return null;
77
+ return {
78
+ ...rawManifest,
79
+ features: { ...rawManifest.features, [feature]: enabled },
80
+ updatedAt: now,
81
+ };
82
+ }
83
+ async function readRawManifest(devflowDir) {
84
+ try {
85
+ return JSON.parse(await fs.readFile(path.join(devflowDir, 'manifest.json'), 'utf-8'));
86
+ }
87
+ catch {
88
+ // ENOENT, EACCES or SyntaxError — no usable manifest.
89
+ return undefined;
90
+ }
91
+ }
92
+ /**
93
+ * Read the machine-wide switch from `<devflowDir>/manifest.json`. Read-only (no
94
+ * heal write); an absent, unreadable or malformed manifest reads as ON.
95
+ */
96
+ export async function readMachineFeature(devflowDir, feature) {
97
+ return isMachineFeatureOn(await readRawManifest(devflowDir), feature);
98
+ }
99
+ /**
100
+ * Write the machine-wide switch to `<devflowDir>/manifest.json` (atomic
101
+ * temp + rename). Refuses with `not-installed` when there is no manifest to
102
+ * write into — a manifest is created by `devflow init`, never by a toggle,
103
+ * because a bare `{features: {...}}` file is not a manifest any reader accepts.
104
+ */
105
+ export async function writeMachineFeature(devflowDir, feature, enabled) {
106
+ const next = setMachineFeature(await readRawManifest(devflowDir), feature, enabled, new Date().toISOString());
107
+ if (next === null)
108
+ return { ok: false, error: 'not-installed' };
109
+ await writeFileAtomicExclusive(path.join(devflowDir, 'manifest.json'), JSON.stringify(next, null, 2) + '\n');
110
+ return { ok: true, value: undefined };
111
+ }
112
+ //# sourceMappingURL=feature-switch.js.map
@@ -121,8 +121,8 @@ export const FLAG_REGISTRY = [
121
121
  kind: 'boolean',
122
122
  target: { type: 'setting', key: 'disableBundledSkills' },
123
123
  onPayload: true,
124
- recommended: true,
125
- defaultValue: true,
124
+ recommended: false,
125
+ defaultValue: false,
126
126
  },
127
127
  {
128
128
  id: 'pin-sonnet-4-6',
@@ -133,8 +133,8 @@ export const FLAG_REGISTRY = [
133
133
  kind: 'boolean',
134
134
  target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL' },
135
135
  onPayload: 'claude-sonnet-4-6',
136
- recommended: true,
137
- defaultValue: true,
136
+ recommended: false,
137
+ defaultValue: false,
138
138
  },
139
139
  {
140
140
  // Devflow fan-outs routinely exceed the upstream default of 20.
@@ -1259,7 +1259,35 @@ export function convergeFlagsIntoSettings(settingsJson, record, opts) {
1259
1259
  }
1260
1260
  // ── Step 3: strip all managed keys, then apply the folded record ──────────
1261
1261
  const stripped = stripFlags(settingsJson);
1262
- const settings = applyFlags(stripped, folded);
1263
- return { settings, record: folded };
1262
+ const applied = JSON.parse(applyFlags(stripped, folded));
1263
+ // ── Step 4: restore the pre-strip key order (D-KEY-ORDER) ────────────────
1264
+ const ordered = orderKeysLike(parsed, applied);
1265
+ const beforeEnv = asPlainObject(parsed.env);
1266
+ const afterEnv = asPlainObject(ordered.env);
1267
+ const settings = beforeEnv && afterEnv
1268
+ ? { ...ordered, env: orderKeysLike(beforeEnv, afterEnv) }
1269
+ : ordered;
1270
+ return { settings: JSON.stringify(settings, null, 2) + '\n', record: folded };
1271
+ }
1272
+ /**
1273
+ * Return `after`'s entries ordered as `before` had them: every key `before` held,
1274
+ * in `before`'s order, then the keys only `after` holds, in `after`'s order.
1275
+ *
1276
+ * D-KEY-ORDER: strip-then-apply deletes every managed key and re-adds it, so each
1277
+ * one lands after whatever key followed it on disk. init merges the security deny
1278
+ * list AFTER the flags, which appends `permissions` behind them; a second init then
1279
+ * moved the flags behind `permissions` and re-init stopped being a no-op on disk
1280
+ * (#388 AC-3). Keeping the pre-converge order makes the first run's order the stable
1281
+ * one: a key that stays keeps its place, a key the record newly sets is appended, a
1282
+ * key it drops is simply absent. Values are `after`'s — only the order is borrowed.
1283
+ *
1284
+ * `Object.fromEntries` defines each key as an own data property, so a `__proto__`
1285
+ * key parsed from settings.json stays a key rather than becoming the prototype.
1286
+ */
1287
+ function orderKeysLike(before, after) {
1288
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
1289
+ const kept = Object.keys(before).filter(k => has(after, k));
1290
+ const added = Object.keys(after).filter(k => !has(before, k));
1291
+ return Object.fromEntries([...kept, ...added].map(k => [k, after[k]]));
1264
1292
  }
1265
1293
  //# sourceMappingURL=flags.js.map
@@ -71,4 +71,31 @@ export async function writeFileAtomicExclusive(filePath, data) {
71
71
  }
72
72
  await fs.rename(tmp, filePath);
73
73
  }
74
+ /**
75
+ * Write a Claude Code settings file (`settings.json`) atomically.
76
+ *
77
+ * D-SETTINGS-ATOMIC: every write of a Claude settings file goes through this one
78
+ * helper, so no command can leave a half-written file for Claude Code — or a
79
+ * concurrent devflow command — to read: the bytes land in a sibling temp file
80
+ * and one rename swaps them in ({@link writeFileAtomicExclusive}).
81
+ *
82
+ * A settings file that is a symbolic link (a dotfiles-managed `settings.json`)
83
+ * stays one: the temp file and the rename target the link's resolved file, so
84
+ * the link keeps pointing where the user pointed it. A dangling link has no file
85
+ * to resolve and is replaced like a missing file.
86
+ */
87
+ export async function writeSettingsFileAtomic(filePath, data) {
88
+ await writeFileAtomicExclusive(await resolveLinkedFile(filePath), data);
89
+ }
90
+ /** `filePath`, or the file it resolves to when it is a symbolic link that resolves. */
91
+ async function resolveLinkedFile(filePath) {
92
+ try {
93
+ if (!(await fs.lstat(filePath)).isSymbolicLink())
94
+ return filePath;
95
+ return await fs.realpath(filePath);
96
+ }
97
+ catch {
98
+ return filePath;
99
+ }
100
+ }
74
101
  //# sourceMappingURL=fs-atomic.js.map
@@ -0,0 +1,104 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ /**
4
+ * @file hook-log-dirs.ts
5
+ *
6
+ * The cap on the hooks' per-directory log folders.
7
+ *
8
+ * D-LOG-DIR-CAP: every hook logs under `~/.devflow/logs/<slug>/`, one directory
9
+ * per working directory a session ran in (`log-paths`' devflow_log_dir). Left
10
+ * uncapped they pile up — a machine was found holding 31k of them, most left by
11
+ * test runs in throwaway temp directories — so `devflow init` keeps the
12
+ * {@link MAX_HOOK_LOG_DIRS} most recently written and removes the rest, oldest
13
+ * first. A folder's recency is the newest mtime among the folder and the log
14
+ * files in it: a log that is only ever appended to leaves the folder's own mtime
15
+ * where the file's creation put it, so the folder mtime alone would rank the
16
+ * busiest project as the oldest.
17
+ *
18
+ * Every pass is bounded — at most {@link MAX_LOG_DIRS_SCANNED} folders read and
19
+ * {@link MAX_LOG_DIRS_PRUNED_PER_RUN} removed. The two bounds are equal, so one
20
+ * init clears every folder it scanned beyond the cap: a 31k backlog costs that
21
+ * init about seven seconds (roughly 2.2 s per 10,000 removals), and only a
22
+ * backlog larger than the scan bound is finished by the next init. The pass is
23
+ * the one-time cleanup of an existing backlog and the standing cap alike.
24
+ * Only directories are touched: files at the logs root (`proxy.log`) and symbolic
25
+ * links are left alone.
26
+ */
27
+ /** How many hook log folders survive a prune — the most recently written ones. */
28
+ export const MAX_HOOK_LOG_DIRS = 200;
29
+ /** The most folders one prune reads; beyond it the rest wait for a later run. */
30
+ export const MAX_LOG_DIRS_SCANNED = 100_000;
31
+ /** The most folders one prune removes — every scanned folder, so one run clears what it read. */
32
+ export const MAX_LOG_DIRS_PRUNED_PER_RUN = MAX_LOG_DIRS_SCANNED;
33
+ /** The most entries read inside one folder to find its newest log. */
34
+ const MAX_FILES_READ_PER_DIR = 64;
35
+ /** Folders stat'ed or removed at once. */
36
+ const IO_CHUNK = 64;
37
+ /** The newest mtime among `dir` and the entries in it; -Infinity when unreadable. */
38
+ async function recencyOf(dir) {
39
+ let newest;
40
+ try {
41
+ newest = (await fs.lstat(dir)).mtimeMs;
42
+ }
43
+ catch {
44
+ return -Infinity;
45
+ }
46
+ let names;
47
+ try {
48
+ names = (await fs.readdir(dir)).slice(0, MAX_FILES_READ_PER_DIR);
49
+ }
50
+ catch {
51
+ return newest;
52
+ }
53
+ const times = await Promise.all(names.map(name => fs.lstat(path.join(dir, name)).then(st => st.mtimeMs, () => -Infinity)));
54
+ return Math.max(newest, ...times);
55
+ }
56
+ /** Apply `fn` to every item, `IO_CHUNK` at a time. Bounded by `items.length`. */
57
+ async function inChunks(items, fn) {
58
+ const out = [];
59
+ for (let i = 0; i < items.length; i += IO_CHUNK) {
60
+ out.push(...await Promise.all(items.slice(i, i + IO_CHUNK).map(fn)));
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Remove the oldest hook log folders under `logsDir` beyond the cap
66
+ * (D-LOG-DIR-CAP). Never throws: a missing logs directory is nothing to do, and
67
+ * a folder that cannot be removed is counted as still over the cap.
68
+ */
69
+ export async function pruneHookLogDirs(logsDir, opts = {}) {
70
+ const keep = opts.keep ?? MAX_HOOK_LOG_DIRS;
71
+ const maxRemovals = opts.maxRemovals ?? MAX_LOG_DIRS_PRUNED_PER_RUN;
72
+ const maxScanned = opts.maxScanned ?? MAX_LOG_DIRS_SCANNED;
73
+ if (!path.isAbsolute(logsDir) || path.basename(logsDir) !== 'logs') {
74
+ return { ok: false, error: `not a devflow logs directory: ${logsDir}` };
75
+ }
76
+ if (keep < 0 || maxRemovals < 0 || maxScanned < 0) {
77
+ return { ok: false, error: 'prune bounds must be non-negative' };
78
+ }
79
+ let entries;
80
+ try {
81
+ entries = await fs.readdir(logsDir, { withFileTypes: true });
82
+ }
83
+ catch (err) {
84
+ if (err.code === 'ENOENT')
85
+ return { ok: true, value: { removed: 0, overCap: 0 } };
86
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
87
+ }
88
+ const dirs = entries
89
+ .filter(e => e.isDirectory())
90
+ .slice(0, maxScanned)
91
+ .map(e => path.join(logsDir, e.name));
92
+ if (dirs.length <= keep)
93
+ return { ok: true, value: { removed: 0, overCap: 0 } };
94
+ const recencies = await inChunks(dirs, recencyOf);
95
+ const oldestFirst = dirs
96
+ .map((dir, i) => ({ dir, recency: recencies[i] }))
97
+ .sort((a, b) => a.recency - b.recency);
98
+ const overCap = oldestFirst.slice(0, oldestFirst.length - keep);
99
+ const batch = overCap.slice(0, maxRemovals);
100
+ const outcomes = await inChunks(batch, ({ dir }) => fs.rm(dir, { recursive: true, force: true }).then(() => true, () => false));
101
+ const removed = outcomes.filter(Boolean).length;
102
+ return { ok: true, value: { removed, overCap: overCap.length - removed } };
103
+ }
104
+ //# sourceMappingURL=hook-log-dirs.js.map
@@ -57,13 +57,15 @@ function readConfigFile(filePath) {
57
57
  * Priority (highest wins): project config → global config → defaults.
58
58
  *
59
59
  * - Global: `~/.devflow/learning.json`
60
- * - Project: `<cwd>/.devflow/learning/learning.json`
60
+ * - Project: `<ledgerRoot>/.devflow/learning/learning.json` — pass the ledger root
61
+ * (getLedgerRoot), where session-start-context reads it and `devflow learning
62
+ * --configure` writes it (D-LEDGER-MAIN-WORKTREE).
61
63
  *
62
64
  * Invalid JSON in either file is silently ignored and treated as absent.
63
65
  */
64
- export function loadLearningTuningConfig(cwd) {
66
+ export function loadLearningTuningConfig(ledgerRoot) {
65
67
  const globalConfigPath = path.join(getDevFlowDirectory(), 'learning.json');
66
- const projectConfigPath = getLearningTuningConfigPath(cwd);
68
+ const projectConfigPath = getLearningTuningConfigPath(ledgerRoot);
67
69
  let config = { ...DEFAULTS };
68
70
  const globalJson = readConfigFile(globalConfigPath);
69
71
  if (globalJson !== null) {