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
@@ -12,10 +12,25 @@
12
12
  * `~/.devflow/scripts` copy is never loaded: it may be older than this CLI. There
13
13
  * is deliberately no TypeScript copy of the parser, the fold or the grammar.
14
14
  *
15
- * D-POLICY-NO-WRITE (applies ADR-024): `.devflow/policy.json` is team-owned, and
15
+ * The same seam loads the sibling `resolve-settings.cjs` (loadSettingsModule),
16
+ * the local resolver of the per-repository settings layer — `.devflow/project.json`,
17
+ * the personal `.devflow/config.json` and the machine manifest. Its shapes are
18
+ * transcribed the same way, and there is no TypeScript copy of its fold either.
19
+ * It also loads the shared strict parser both resolvers use,
20
+ * `lib/project-config.cjs` (loadProjectConfigLib), so the CLI judges a config
21
+ * file's bytes exactly as the resolvers do.
22
+ *
23
+ * D-POLICY-NO-WRITE (applies ADR-024): `.devflow/project.json` is team-owned, and
16
24
  * devflow never writes or replaces a shared file it cannot prove it wrote. This
17
25
  * module therefore imports no fs API; the CLI only PRINTS the bytes a team may
18
- * choose to commit (`evidencePolicySuggestion`).
26
+ * choose to commit (`evidencePolicySuggestion`, and the migration lines of
27
+ * `repoComplianceStatusLines`), all from the settings resolver's project.json
28
+ * serializer.
29
+ *
30
+ * D-POLICY-JSON-RETIRED: the evidence resolver never parses `.devflow/policy.json`;
31
+ * at a source whose project.json has no `evidence`, the file's presence alone
32
+ * resolves `required` (see the resolver's own note). This side neither reads nor
33
+ * serializes it — it only names it in the migration hint.
19
34
  */
20
35
  import { createRequire } from 'module';
21
36
  import { join } from 'path';
@@ -23,8 +38,14 @@ import { scriptsDir } from './assets.js';
23
38
  // ── Transcribed shapes (resolve-evidence-policy.cjs JSDoc) ─────────────────────
24
39
  /** Basename of the resolver under src/assets/scripts/ (and ~/.devflow/scripts/). */
25
40
  export const RESOLVER_SCRIPT_NAME = 'resolve-evidence-policy.cjs';
41
+ /** Basename of the settings resolver under src/assets/scripts/ (and ~/.devflow/scripts/). */
42
+ export const SETTINGS_SCRIPT_NAME = 'resolve-settings.cjs';
43
+ /** The shared strict config parser, relative to src/assets/scripts/ (and ~/.devflow/scripts/). */
44
+ export const PROJECT_CONFIG_LIB_NAME = join('lib', 'project-config.cjs');
26
45
  /** The team file the CLI suggests committing, relative to a repository root. */
27
- const POLICY_FILE = '.devflow/policy.json';
46
+ const PROJECT_FILE = '.devflow/project.json';
47
+ /** The retired team file project.json's `evidence` replaces, relative to a repository root. */
48
+ const RETIRED_POLICY_FILE = '.devflow/policy.json';
28
49
  /**
29
50
  * Every key of EvidencePolicyModule and the runtime kind the loader requires of
30
51
  * it. `satisfies` makes the compiler reject an interface key missing here.
@@ -38,7 +59,20 @@ export const EVIDENCE_POLICY_MODULE_SURFACE = Object.freeze({
38
59
  FAIL_CLOSED_LINE: 'string',
39
60
  complianceDefault: 'function',
40
61
  resolve: 'function',
41
- serializePolicy: 'function',
62
+ });
63
+ /** Every key of SettingsModule and the runtime kind the loader requires of it. */
64
+ export const SETTINGS_MODULE_SURFACE = Object.freeze({
65
+ SETTINGS_LINE_RE: 'regexp',
66
+ SETTINGS_FAIL_CLOSED_LINE: 'string',
67
+ resolveSettings: 'function',
68
+ serializeProjectSuggestion: 'function',
69
+ });
70
+ /** Every key of ProjectConfigLib and the runtime kind the loader requires of it. */
71
+ export const PROJECT_CONFIG_LIB_SURFACE = Object.freeze({
72
+ MAX_CONFIG_BYTES: 'number',
73
+ decodeConfigBytes: 'function',
74
+ readBoundedRegularFile: 'function',
75
+ collectDuplicateKeyPaths: 'function',
42
76
  });
43
77
  function hasKind(value, kind) {
44
78
  switch (kind) {
@@ -46,6 +80,7 @@ function hasKind(value, kind) {
46
80
  case 'object': return typeof value === 'object' && value !== null;
47
81
  case 'regexp': return value instanceof RegExp;
48
82
  case 'string': return typeof value === 'string';
83
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
49
84
  case 'function': return typeof value === 'function';
50
85
  default: {
51
86
  const exhaustive = kind;
@@ -54,21 +89,21 @@ function hasKind(value, kind) {
54
89
  }
55
90
  }
56
91
  /** Surface keys that are absent or of the wrong kind on `value`, in surface order. */
57
- function surfaceMismatches(value) {
92
+ function surfaceMismatches(value, surface) {
58
93
  if (typeof value !== 'object' || value === null)
59
- return Object.keys(EVIDENCE_POLICY_MODULE_SURFACE);
94
+ return Object.keys(surface);
60
95
  const record = value;
61
- return Object.entries(EVIDENCE_POLICY_MODULE_SURFACE)
96
+ return Object.entries(surface)
62
97
  .filter(([key, kind]) => !hasKind(record[key], kind))
63
98
  .map(([key]) => key);
64
99
  }
65
100
  /**
66
- * Load the resolver from `dir` (default: the package's own scripts directory) and
67
- * shape-check its surface. Never throws: a missing file is `not-found`; a module
68
- * that throws on load or lacks a surface key is `unusable`.
101
+ * require() one package script and shape-check it against `surface`. Never
102
+ * throws: a missing file is `not-found`; a module that throws on load or lacks a
103
+ * surface key is `unusable`. The caller's type parameter is justified by the
104
+ * surface check, which `satisfies` ties to the interface's keys.
69
105
  */
70
- export function loadEvidencePolicyModule(dir = scriptsDir()) {
71
- const file = join(dir, RESOLVER_SCRIPT_NAME);
106
+ function loadScript(file, surface) {
72
107
  let loaded;
73
108
  try {
74
109
  loaded = createRequire(import.meta.url)(file);
@@ -80,12 +115,34 @@ export function loadEvidencePolicyModule(dir = scriptsDir()) {
80
115
  const detail = err instanceof Error ? err.message : String(err);
81
116
  return { ok: false, error: { kind: 'unusable', path: file, detail } };
82
117
  }
83
- const mismatches = surfaceMismatches(loaded);
118
+ const mismatches = surfaceMismatches(loaded, surface);
84
119
  if (mismatches.length > 0) {
85
120
  return { ok: false, error: { kind: 'unusable', path: file, detail: `missing or mistyped: ${mismatches.join(', ')}` } };
86
121
  }
87
122
  return { ok: true, value: loaded };
88
123
  }
124
+ /**
125
+ * Load the evidence resolver from `dir` (default: the package's own scripts
126
+ * directory) and shape-check its surface.
127
+ */
128
+ export function loadEvidencePolicyModule(dir = scriptsDir()) {
129
+ return loadScript(join(dir, RESOLVER_SCRIPT_NAME), EVIDENCE_POLICY_MODULE_SURFACE);
130
+ }
131
+ /**
132
+ * Load the settings resolver from `dir` (default: the package's own scripts
133
+ * directory) and shape-check its surface. A `resolveSettings()` call makes one
134
+ * local `git` call and no network call (D-SETTINGS-LOCAL-ONLY).
135
+ */
136
+ export function loadSettingsModule(dir = scriptsDir()) {
137
+ return loadScript(join(dir, SETTINGS_SCRIPT_NAME), SETTINGS_MODULE_SURFACE);
138
+ }
139
+ /**
140
+ * Load the shared strict config parser from `dir` (default: the package's own
141
+ * scripts directory) and shape-check its surface.
142
+ */
143
+ export function loadProjectConfigLib(dir = scriptsDir()) {
144
+ return loadScript(join(dir, PROJECT_CONFIG_LIB_NAME), PROJECT_CONFIG_LIB_SURFACE);
145
+ }
89
146
  // ── Presentation (pure) ────────────────────────────────────────────────────────
90
147
  /** `Evidence policy: <policy> (source: <source>)`, plus ` [warn: a, b]` when warnings exist. */
91
148
  export function formatEvidencePolicyStatus(r) {
@@ -111,7 +168,7 @@ export function formatEvidencePolicyUnavailable(error) {
111
168
  * The `compliance --status` line: the resolved policy for `opts.dir`, or the
112
169
  * unavailable line when the loader failed — that line is the whole handling
113
170
  * (ADR-028). The caller passes the compliance state it already read, so the
114
- * manifest is never read twice. `resolve()` makes at most two `gh` calls and
171
+ * manifest is never read twice. `resolve()` makes at most three `gh` calls and
115
172
  * bounds every subprocess with a timeout, so an offline machine degrades to a
116
173
  * flagged result rather than a hang.
117
174
  */
@@ -121,27 +178,186 @@ export function evidencePolicyStatusLine(loaded, opts) {
121
178
  return formatEvidencePolicyStatus(loaded.value.resolve(opts));
122
179
  }
123
180
  /**
124
- * What `--enable`/`--set` print when compliance is on: the file a team may commit
125
- * to pin the policy it now gets by default. Returned only when the resolver's own
181
+ * The frameworks a compliance state names, for the suggestion: the raw list when
182
+ * the state is well-formed, else none. The settings resolver's serializer
183
+ * normalizes and drops unknown ids, so no id reaches the printed bytes unchecked.
184
+ */
185
+ function suggestedFrameworks(complianceState) {
186
+ if (typeof complianceState !== 'object' || complianceState === null)
187
+ return [];
188
+ const frameworks = complianceState.frameworks;
189
+ return Array.isArray(frameworks) && frameworks.every(f => typeof f === 'string') ? frameworks : [];
190
+ }
191
+ /**
192
+ * What `--enable`/`--set` print when compliance is on: the keys to add to a
193
+ * repository's `.devflow/project.json` on its default branch — merged into the
194
+ * file when it already has one, never replacing it — to hold every developer to
195
+ * what this machine now gets by default: the required evidence policy and this
196
+ * machine's frameworks. Returned only when the evidence resolver's own
126
197
  * `complianceDefault` says `required` (compliance enabled, at any framework
127
- * count); `null` otherwise. The bytes come from the resolver's `serializePolicy`,
128
- * so they always parse as a valid policy file. Nothing is written
129
- * (D-POLICY-NO-WRITE).
198
+ * count); `null` otherwise. The bytes come
199
+ * from the settings resolver's `serializeProjectSuggestion`, which returns them
200
+ * only when they read back through the shared parser as exactly what was asked.
201
+ * Nothing is written (D-POLICY-NO-WRITE, applies ADR-024).
130
202
  */
131
- export function evidencePolicySuggestion(complianceState, mod) {
132
- if (mod.complianceDefault(complianceState) !== 'required')
203
+ export function evidencePolicySuggestion(complianceState, policy, settings) {
204
+ if (policy.complianceDefault(complianceState) !== 'required')
133
205
  return null;
134
- const body = mod.serializePolicy('required');
206
+ const body = settings.serializeProjectSuggestion({
207
+ evidence: 'required',
208
+ compliance: suggestedFrameworks(complianceState),
209
+ });
135
210
  if (body === null)
136
211
  return null;
137
212
  return [
138
213
  'Compliance is enabled on this machine, so repositories without a committed',
139
- 'policy default to the required evidence policy here. To apply it for everyone',
140
- `working in a repository, commit this as ${POLICY_FILE} on its default branch:`,
214
+ 'evidence setting default to the required evidence policy here. To apply it for',
215
+ `everyone working in a repository, add these keys to its ${PROJECT_FILE} on its`,
216
+ 'default branch — merged into the file when it already has one, never replacing it:',
141
217
  '',
142
218
  `${body}`,
143
219
  'devflow never writes this file: the team owns it, and once committed it applies',
144
220
  'repo-wide.',
145
221
  ].join('\n');
146
222
  }
223
+ // ── The settings layer, for `--status` (pure) ──────────────────────────────────
224
+ /** A repo layer's file, as a `--status` line names it. */
225
+ export function settingsSourceFile(source) {
226
+ switch (source) {
227
+ case 'project': return PROJECT_FILE;
228
+ case 'personal': return '.devflow/config.json';
229
+ default: {
230
+ const exhaustive = source;
231
+ return exhaustive;
232
+ }
233
+ }
234
+ }
235
+ /**
236
+ * The effective state of a feature switch in this repository, ONLY when a repo
237
+ * layer narrows it — `disabled (.devflow/project.json)` — and null otherwise, so a
238
+ * `--status` whose machine switch alone decides prints exactly what it always has
239
+ * (D-FEATURES-NARROW-ONLY). A repository file that exists but is unreadable fails
240
+ * every field closed but the compliance lens, and a switch that closed off is
241
+ * labelled with that file —
242
+ * `disabled (.devflow/project.json is unreadable)` — since commands act on it. Any
243
+ * other failure (the resolver failed to load, or git could not answer) yields
244
+ * null: it knows nothing about this repository.
245
+ */
246
+ export function narrowedSwitchLabel(loaded, opts, feature) {
247
+ if (!loaded.ok)
248
+ return null;
249
+ const settings = loaded.value.resolveSettings(opts);
250
+ if (!settings.ok) {
251
+ if (settings.unreadable === null || settings.switches[feature].on)
252
+ return null;
253
+ return `disabled (${settingsSourceFile(settings.unreadable)} is unreadable)`;
254
+ }
255
+ const state = settings.switches[feature];
256
+ if (state.on || state.source === 'machine')
257
+ return null;
258
+ return `disabled (${settingsSourceFile(state.source)})`;
259
+ }
260
+ /**
261
+ * The tracker in effect in the repository at `opts.dir`, ONLY when a repository
262
+ * layer decides it — its committed project.json, or the personal config.json
263
+ * narrowing — and null otherwise. A machine whose own selection (or the github
264
+ * default) decides gets null, so `tracker --status` prints exactly what it always
265
+ * has there. So does a resolver that failed to load or failed closed: it knows
266
+ * nothing about this repository, and a fail-closed `github` is not a selection
267
+ * anyone made.
268
+ */
269
+ export function repoTrackerSelection(loaded, opts) {
270
+ if (!loaded.ok)
271
+ return null;
272
+ const settings = loaded.value.resolveSettings(opts);
273
+ if (!settings.ok)
274
+ return null;
275
+ const source = settings.trackerSource;
276
+ if (source !== 'project' && source !== 'personal')
277
+ return null;
278
+ return { provider: settings.tracker, source };
279
+ }
280
+ /** A declared id list as a `--status` line shows it. */
281
+ function idsLabel(ids) {
282
+ return ids.length > 0 ? ids.join(', ') : 'generic controls only';
283
+ }
284
+ /**
285
+ * The `compliance --status` lines about the repository in `opts.dir`, mirroring
286
+ * the resolver's lens fold (D-LENS-UNION: machine ∪ default branch ∪ worktree):
287
+ * the ids this checkout's project.json declares (`generic controls only` for an
288
+ * empty or malformed list), the ids the default branch's copy declares, the
289
+ * effective lens those add up to with the machine's, and a migration hint while
290
+ * the retired policy file is in the working tree.
291
+ *
292
+ * A broken file affects only the keys it owns. An unreadable project.json is a
293
+ * malformed declaration — generic — and says so, naming the file; an unreadable
294
+ * config.json owns no compliance, so the lines are those of a readable one. Empty
295
+ * when the resolver is unavailable or failed closed for any other reason, or no
296
+ * repository layer declares anything and there is no policy file — the status
297
+ * output is then unchanged.
298
+ *
299
+ * The hint states the rule (D-POLICY-JSON-RETIRED): the file is not read, and
300
+ * while project.json has no `evidence` its presence holds the repository at
301
+ * `required`. The value is not read either, so the hint shows the project.json
302
+ * line for each value the file may hold, from the settings resolver's serializer.
303
+ */
304
+ export function repoComplianceStatusLines(loaded, opts) {
305
+ if (!loaded.ok)
306
+ return [];
307
+ const settings = loaded.value.resolveSettings(opts);
308
+ if (!settings.ok && settings.unreadable === null)
309
+ return [];
310
+ const lines = [];
311
+ if (settings.unreadable === 'project') {
312
+ lines.push(`Repository: generic controls only (${PROJECT_FILE} is unreadable)`);
313
+ }
314
+ else if (settings.repoCompliance !== null) {
315
+ lines.push(`Repository: ${idsLabel(settings.repoCompliance)} (${PROJECT_FILE})`);
316
+ }
317
+ if (settings.defaultBranchCompliance !== null) {
318
+ lines.push(`Default branch: ${idsLabel(settings.defaultBranchCompliance)} (its ${PROJECT_FILE})`);
319
+ }
320
+ if (lines.length > 0) {
321
+ const lens = settings.compliance;
322
+ lines.push(`Effective here: ${lens.enabled ? idsLabel(lens.frameworks) : 'off'} (this machine + the default branch + this checkout)`);
323
+ }
324
+ if (settings.retiredPolicyFile)
325
+ lines.push(...retiredPolicyHint(loaded.value));
326
+ return lines;
327
+ }
328
+ /**
329
+ * The warning a `--status` prints when this checkout's `.devflow/config.json` is
330
+ * tracked by git, or null (D-PERSONAL-UNTRACKED). The resolver ignores such a file
331
+ * and says so on stderr, but prompts run it with stderr discarded, so a status
332
+ * command is where the user sees why their personal settings have no effect.
333
+ */
334
+ export function personalConfigTrackedWarning(loaded, opts) {
335
+ if (!loaded.ok)
336
+ return null;
337
+ if (!loaded.value.resolveSettings(opts).personalTracked)
338
+ return null;
339
+ const file = settingsSourceFile('personal');
340
+ return `${file} is tracked by git, so devflow ignores it — it holds personal settings. ` +
341
+ `Untrack it with: git rm --cached ${file}`;
342
+ }
343
+ /** The policies the hint maps, in the order it prints them. */
344
+ const HINT_POLICIES = ['standard', 'required'];
345
+ /**
346
+ * The migration hint for a working tree holding the retired policy file: what the
347
+ * file does now, and the project.json line that states each value it may hold.
348
+ * A value whose line the serializer refuses is left out rather than hand-built.
349
+ */
350
+ function retiredPolicyHint(settings) {
351
+ const mappings = HINT_POLICIES.flatMap((policy) => {
352
+ const body = settings.serializeProjectSuggestion({ evidence: policy });
353
+ return body === null ? [] : [` ${policy.padEnd(8)} → ${body.trimEnd()}`];
354
+ });
355
+ return [
356
+ `Migration: ${RETIRED_POLICY_FILE} is not read. While ${PROJECT_FILE} has no "evidence",`,
357
+ ' its presence alone holds this repository at required. Add its value to',
358
+ ` ${PROJECT_FILE} as "evidence", and keep ${RETIRED_POLICY_FILE} until every`,
359
+ ` teammate runs devflow 3.0 or later; only then delete it:`,
360
+ ...mappings,
361
+ ];
362
+ }
147
363
  //# sourceMappingURL=evidence-policy.js.map
@@ -2,16 +2,19 @@ import * as path from 'path';
2
2
  import { promises as fs } from 'fs';
3
3
  import { getFeatureConfigPath } from './project-paths.js';
4
4
  import { parseTrackerId } from './tracker.js';
5
+ import { loadProjectConfigLib } from './evidence-policy.js';
5
6
  /**
6
7
  * Keys devflow itself once wrote and has retired. A managed write drops them
7
8
  * rather than carrying them; no reader consults them.
8
9
  *
9
- * D-FEATURES-MACHINE-WIDE: `memory`, `learning` and `knowledge` were per-repo
10
- * feature switches from the per-repo-install era. A feature is now on or off
11
- * for the whole machine, in the manifest, so a stale per-repo value must
12
- * neither decide anything nor linger to be mistaken for a switch: carrying it
13
- * would leave a `learning: false` in the file that no longer does what it says.
14
- * `decisions` is the pre-rename spelling of `learning`; `autoCommit` is inert.
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).
15
18
  */
16
19
  const RETIRED_CONFIG_KEYS = new Set([
17
20
  'memory', 'learning', 'knowledge', 'decisions', 'autoCommit',
@@ -65,7 +68,7 @@ function isJsonObject(value) {
65
68
  * DEFAULT_CONFIG. Pure function — no I/O, no side effects.
66
69
  *
67
70
  * The retired keys ({@link RETIRED_CONFIG_KEYS}) are ignored: an old config may
68
- * still hold them, and none of them decides anything (D-FEATURES-MACHINE-WIDE).
71
+ * still hold them, and none of them decides anything (D-FEATURES-NARROW-ONLY).
69
72
  *
70
73
  * Returns null when `parsed` is not a plain object (caller falls through to
71
74
  * the next candidate path).
@@ -99,25 +102,72 @@ function coerceConfig(parsed) {
99
102
  }
100
103
  /**
101
104
  * Read the per-repo config for a project root.
102
- * Returns DEFAULT_CONFIG when the file is missing or unreadable.
105
+ * Returns DEFAULT_CONFIG when the file is missing or unusable.
103
106
  */
104
107
  export async function readConfig(projectRoot) {
105
- return coerceConfig(await readConfigBody(projectRoot)) ?? { ...DEFAULT_CONFIG };
108
+ return coerceConfig(objectOf(await readConfigBody(projectRoot))) ?? { ...DEFAULT_CONFIG };
106
109
  }
107
110
  /**
108
- * The parsed JSON body of a project's config file, or `undefined` when the file
109
- * is absent, unreadable or malformed. Every read of the file goes through here,
110
- * so readConfig, readConfigIfPresent and writeManagedConfig agree on what an
111
- * unusable file means.
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`.
112
119
  */
113
- async function readConfigBody(projectRoot) {
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;
114
127
  try {
115
- return JSON.parse(await fs.readFile(getFeatureConfigPath(projectRoot), 'utf-8'));
128
+ parsed = JSON.parse(decoded.text);
116
129
  }
117
130
  catch {
118
- // ENOENT (absent) or SyntaxError (malformed) — treat as not present
119
- return undefined;
131
+ return { kind: 'malformed' };
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;
143
+ }
144
+ /**
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
+ };
120
169
  }
170
+ return classifyConfigBytes(read.bytes, lib.value);
121
171
  }
122
172
  /**
123
173
  * Serialise a config body to a project's config file.
@@ -146,8 +196,8 @@ async function writeConfigBody(projectRoot, body) {
146
196
  * `managed`, by name, so a caller holding a whole FeatureConfig still cannot
147
197
  * overwrite the file's override with its in-memory copy. The retired keys in
148
198
  * RETIRED_CONFIG_KEYS are dropped, not carried. A body that is
149
- * not a JSON object (absent, malformed, an array) reads as empty, exactly as
150
- * readConfigIfPresent treats it.
199
+ * not a JSON object reads as empty, exactly as readConfigIfPresent treats it;
200
+ * writeManagedConfig never reaches here with a malformed file.
151
201
  *
152
202
  * This holds under `devflow init --reset` too: a factory reset returns
153
203
  * devflow's own settings to their defaults through `managed`, and leaves the
@@ -164,16 +214,35 @@ export function mergeManagedConfig(existing, managed) {
164
214
  }
165
215
  /**
166
216
  * Write devflow's managed keys to a project's config, keeping every key it
167
- * does not manage (D-CONFIG-PRESERVE-UNMANAGED).
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.
168
225
  *
169
226
  * D1: Non-atomic read-modify-write. A concurrent writer could lose the other's
170
227
  * change. Acceptable because init is a single-threaded, user-initiated command
171
228
  * and the window is milliseconds on a local filesystem; the file swap itself is
172
229
  * atomic (temp + rename), so a reader never sees a partial file.
173
230
  */
174
- export async function writeManagedConfig(projectRoot, managed) {
175
- const existing = await readConfigBody(projectRoot);
176
- await writeConfigBody(projectRoot, mergeManagedConfig(existing, managed));
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 };
177
246
  }
178
247
  /**
179
248
  * Read the per-repo config for a project root, returning null when the file
@@ -185,7 +254,7 @@ export async function writeManagedConfig(projectRoot, managed) {
185
254
  * distinction to carry a repo's own reviewPublication across a re-init.
186
255
  */
187
256
  export async function readConfigIfPresent(projectRoot) {
188
- // null when the file is absent or malformed, or its JSON is not a plain object
189
- return coerceConfig(await readConfigBody(projectRoot));
257
+ // null when the file is absent, malformed or unreadable
258
+ return coerceConfig(objectOf(await readConfigBody(projectRoot)));
190
259
  }
191
260
  //# sourceMappingURL=feature-config.js.map
@@ -23,7 +23,7 @@ const LEGACY_KEYS = {
23
23
  * shell hooks, so the CLI's status and the runtime never disagree about the
24
24
  * same file.
25
25
  *
26
- * D-LEARNING-LEGACY-DECISIONS (a sub-decision of D-FEATURES-MACHINE-WIDE):
26
+ * D-LEARNING-LEGACY-DECISIONS (a sub-decision of D-FEATURES-NARROW-ONLY):
27
27
  * `learning` is read as `features.learning` when that is a boolean, else the
28
28
  * legacy `features.decisions` when THAT is a boolean, else ON — readManifest's
29
29
  * migration precedence exactly. The legacy key is otherwise honoured only once
@@ -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