devflow-kit 2.0.1 → 2.1.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.
@@ -1,139 +1,244 @@
1
1
  /**
2
- * Claude Code flag registry.
2
+ * Claude Code flag registry — typed, extensible mechanism for managing
3
+ * Claude Code feature flags and settings.
3
4
  *
4
- * Typed, extensible mechanism for managing Claude Code feature flags.
5
- * Pure functions: applyFlags, stripFlags, getDefaultFlags — no I/O.
5
+ * Pure functions: applyFlags, stripFlags, getDefaultFlagsRecord — no I/O.
6
+ *
7
+ * D14: Typed registry — flags carry kind (boolean|enum|number|string), target
8
+ * (env|setting), and per-kind defaultValue. Neutral values delete their target
9
+ * key; active values write the appropriate payload. Number 0 is ACTIVE. Sink
10
+ * validation via coerceFlagValue (applies PF-023: validate at the convergence
11
+ * point every caller reaches). applyFlags(settingsJson, FlagsRecord) is the
12
+ * sole API; init.ts works directly with FlagsRecord (no legacy string[] bridge).
13
+ */
14
+ // ─── Registry ─────────────────────────────────────────────────────────────────
15
+ // Phase 0 probe findings: see docs/reference/claude-code-flags-probe.md
16
+ /**
17
+ * Ordered registry of all Claude Code flags managed by devflow.
18
+ *
19
+ * IDs are the stable manifest keys (`features.flags` in the devflow manifest).
20
+ * Array order drives the `--list` table and TUI row order — intentional changes
21
+ * to order are display changes and should be made deliberately.
22
+ *
23
+ * Not every Claude Code env var belongs here. One notable exclusion:
24
+ * `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` — deliberately
25
+ * proxy-owned. It is paired with `ANTHROPIC_BASE_URL` in proxy.ts and its
26
+ * lifecycle is coupled to relay enable/disable; strip is handled by
27
+ * `stripProxyEnv` (src/cli/commands/proxy.ts). Adding it here would create a
28
+ * second owner and double-strip it on uninstall. (mirrors agent-teams note)
6
29
  */
7
30
  export const FLAG_REGISTRY = [
8
- // === Recommended (default ON) ===
31
+ // ══ Recommended (default ON) ══════════════════════════════════════════════
9
32
  {
10
33
  id: 'tui',
11
34
  label: 'Fullscreen terminal UI',
12
35
  description: 'Flicker-free fullscreen rendering',
13
- hint: 'Modern fullscreen interface',
14
- target: { type: 'setting', key: 'tui', value: 'fullscreen' },
15
- defaultEnabled: true,
36
+ hint: 'Enables fullscreen mode — flicker-free and cursor-stable',
37
+ blurb: 'fullscreen terminal UI',
38
+ kind: 'boolean',
39
+ target: { type: 'setting', key: 'tui' },
40
+ onPayload: 'fullscreen',
41
+ recommended: true,
42
+ defaultValue: true,
16
43
  },
17
44
  {
18
45
  id: 'tool-search',
19
46
  label: 'Deferred tool loading',
20
47
  description: 'Load tool schemas on demand instead of all at startup',
21
- hint: 'Faster startup',
22
- target: { type: 'env', key: 'ENABLE_TOOL_SEARCH', value: 'true' },
23
- defaultEnabled: true,
48
+ hint: 'Defers tool schema loading to first use — smaller initial context',
49
+ blurb: 'deferred tool schema loading',
50
+ kind: 'boolean',
51
+ target: { type: 'env', key: 'ENABLE_TOOL_SEARCH' },
52
+ onPayload: 'true',
53
+ recommended: true,
54
+ defaultValue: true,
24
55
  },
25
56
  {
26
57
  id: 'lsp',
27
58
  label: 'LSP support',
28
59
  description: 'Enable Language Server Protocol integration',
29
- hint: 'Code intelligence from your editor',
30
- target: { type: 'env', key: 'ENABLE_LSP_TOOL', value: 'true' },
31
- defaultEnabled: true,
60
+ hint: 'Activates LSP tool so Claude can query your editor code intelligence',
61
+ blurb: 'editor code intelligence',
62
+ kind: 'boolean',
63
+ target: { type: 'env', key: 'ENABLE_LSP_TOOL' },
64
+ onPayload: 'true',
65
+ recommended: true,
66
+ defaultValue: true,
32
67
  },
33
68
  {
34
69
  id: 'prompt-caching-1h',
35
70
  label: 'Extended prompt cache',
36
71
  description: 'Extend prompt cache TTL from 5min to 1h',
37
- hint: 'Cheaper long sessions',
38
- target: { type: 'env', key: 'ENABLE_PROMPT_CACHING_1H', value: 'true' },
39
- defaultEnabled: true,
72
+ hint: 'Extends cache TTL from 5 min to 1 hr — cheaper long sessions',
73
+ blurb: '1-hour prompt cache TTL',
74
+ kind: 'boolean',
75
+ target: { type: 'env', key: 'ENABLE_PROMPT_CACHING_1H' },
76
+ onPayload: 'true',
77
+ recommended: true,
78
+ defaultValue: true,
40
79
  },
41
80
  {
42
81
  id: 'show-turn-duration',
43
82
  label: 'Show turn duration',
44
83
  description: 'Display timing info after each turn',
45
- hint: 'See how long each response takes',
46
- target: { type: 'setting', key: 'showTurnDuration', value: true },
47
- defaultEnabled: true,
84
+ hint: 'Shows wall-clock time for each turn — useful for spotting slow paths',
85
+ blurb: 'wall-clock time per turn',
86
+ kind: 'boolean',
87
+ target: { type: 'setting', key: 'showTurnDuration' },
88
+ onPayload: true,
89
+ recommended: true,
90
+ defaultValue: true,
48
91
  },
49
92
  {
50
93
  id: 'clear-context-on-plan',
51
94
  label: 'Clear context on plan accept',
52
95
  description: 'Clear context window when accepting a plan',
53
- hint: 'Clean slate after planning',
54
- target: { type: 'setting', key: 'showClearContextOnPlanAccept', value: true },
55
- defaultEnabled: true,
96
+ hint: 'Clears context on plan accept so implementation starts with full budget',
97
+ blurb: 'clear context on plan accept',
98
+ kind: 'boolean',
99
+ target: { type: 'setting', key: 'showClearContextOnPlanAccept' },
100
+ onPayload: true,
101
+ recommended: true,
102
+ defaultValue: true,
56
103
  },
57
104
  {
58
105
  id: 'disable-bundled-skills',
59
106
  label: 'Disable bundled skills',
60
107
  description: "Remove Claude Code's built-in skills and workflows (devflow provides its own)",
61
- hint: 'Cleaner skill list',
62
- target: { type: 'setting', key: 'disableBundledSkills', value: true },
63
- defaultEnabled: true,
108
+ hint: "Removes Claude Code's built-in skills — devflow installs its own set",
109
+ blurb: 'remove built-in CC skills',
110
+ kind: 'boolean',
111
+ target: { type: 'setting', key: 'disableBundledSkills' },
112
+ onPayload: true,
113
+ recommended: true,
114
+ defaultValue: true,
64
115
  },
65
116
  {
66
117
  id: 'pin-sonnet-4-6',
67
118
  label: 'Pin Sonnet to 4.6',
68
119
  description: 'Pin the default Sonnet model to claude-sonnet-4-6',
69
- hint: 'Stable, deterministic Sonnet version',
70
- target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: 'claude-sonnet-4-6' },
71
- defaultEnabled: true,
120
+ hint: 'Pins Sonnet to 4.6 — stable, deterministic alias across model updates',
121
+ blurb: 'pin Sonnet to 4.6 model',
122
+ kind: 'boolean',
123
+ target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL' },
124
+ onPayload: 'claude-sonnet-4-6',
125
+ recommended: true,
126
+ defaultValue: true,
127
+ },
128
+ {
129
+ // Devflow fan-outs routinely exceed the upstream default of 20.
130
+ // Set to 40 by default so parallel Code/Review/Research waves don't
131
+ // silently queue. upstreamDefault recorded for display. (applies PF-023 bounds)
132
+ id: 'max-concurrent-subagents',
133
+ label: 'Max concurrent subagents',
134
+ description: 'Maximum number of subagents Claude Code will spawn concurrently',
135
+ hint: 'Sets concurrent subagent cap; upstream default is 20 — devflow uses 40',
136
+ blurb: 'parallel subagent cap',
137
+ kind: 'number',
138
+ target: { type: 'env', key: 'CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS' },
139
+ recommended: true,
140
+ defaultValue: 40,
141
+ min: 1,
142
+ max: 100, // devflow sanity bound (applies PF-023)
143
+ integer: true,
144
+ upstreamDefault: 20,
72
145
  },
73
- // === Optional (default OFF) — skip these if you're unsure ===
146
+ // ══ Optional (default OFF) — skip these if you're unsure ══════════════════
74
147
  {
75
148
  id: 'brief',
76
149
  label: 'Brief output mode',
77
150
  description: 'Reduce verbosity of Claude Code output',
78
- hint: 'Shorter responses',
79
- target: { type: 'env', key: 'CLAUDE_CODE_BRIEF', value: 'true' },
80
- defaultEnabled: false,
151
+ hint: 'Reduces output verbosity — shorter responses, less explanation',
152
+ blurb: 'shorter, less verbose output',
153
+ kind: 'boolean',
154
+ target: { type: 'env', key: 'CLAUDE_CODE_BRIEF' },
155
+ onPayload: 'true',
156
+ recommended: false,
157
+ defaultValue: false,
81
158
  },
82
159
  {
83
160
  id: 'thinking-summaries',
84
161
  label: 'Thinking summaries',
85
162
  description: 'Show thinking summaries during reasoning',
86
- hint: 'See reasoning previews',
87
- target: { type: 'setting', key: 'showThinkingSummaries', value: true },
88
- defaultEnabled: false,
163
+ hint: 'Surfaces condensed reasoning previews during extended thinking',
164
+ blurb: 'condensed reasoning previews',
165
+ kind: 'boolean',
166
+ target: { type: 'setting', key: 'showThinkingSummaries' },
167
+ onPayload: true,
168
+ recommended: false,
169
+ defaultValue: false,
89
170
  },
90
171
  {
91
172
  id: 'subprocess-env-scrub',
92
173
  label: 'Subprocess env scrub',
93
174
  description: 'Strip cloud credentials from subprocesses',
94
- hint: 'Security: strip cloud creds from subprocesses',
95
- target: { type: 'env', key: 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB', value: '1' },
96
- defaultEnabled: false,
175
+ hint: 'Strips cloud credentials (AWS, GCP, Azure) from subprocess env',
176
+ blurb: 'strip cloud credentials',
177
+ kind: 'boolean',
178
+ target: { type: 'env', key: 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB' },
179
+ onPayload: '1',
180
+ recommended: false,
181
+ defaultValue: false,
97
182
  },
98
183
  {
99
184
  id: 'disable-nonessential-traffic',
100
185
  label: 'Disable non-essential traffic',
101
186
  description: 'Suppress usage metrics telemetry',
102
- hint: 'No telemetry',
103
- target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: 'true' },
104
- defaultEnabled: false,
187
+ hint: 'Suppresses usage telemetry sent back to Anthropic',
188
+ blurb: 'suppress usage telemetry',
189
+ kind: 'boolean',
190
+ target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' },
191
+ onPayload: 'true',
192
+ recommended: false,
193
+ defaultValue: false,
105
194
  },
106
195
  {
107
196
  id: 'forked-subagents',
108
197
  label: 'Forked subagents',
109
198
  description: 'Better subagent perf on external builds',
110
- hint: 'Faster parallel agents (experimental)',
111
- target: { type: 'env', key: 'CLAUDE_CODE_FORK_SUBAGENT', value: '1' },
112
- defaultEnabled: false,
199
+ hint: 'Enables forked subagent model — faster parallel agents (experimental)',
200
+ blurb: 'faster parallel agents',
201
+ kind: 'boolean',
202
+ target: { type: 'env', key: 'CLAUDE_CODE_FORK_SUBAGENT' },
203
+ onPayload: '1',
204
+ recommended: false,
205
+ defaultValue: false,
113
206
  },
114
207
  {
115
208
  id: 'disable-adaptive-thinking',
116
209
  label: 'Disable adaptive thinking',
117
210
  description: 'Disable adaptive reasoning on Opus/Sonnet 4.6',
118
- hint: 'Fixed thinking budget',
119
- target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING', value: 'true' },
120
- defaultEnabled: false,
211
+ hint: 'Disables adaptive thinking budget — fixes compute per turn',
212
+ blurb: 'fixed compute per turn',
213
+ kind: 'boolean',
214
+ target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING' },
215
+ onPayload: 'true',
216
+ recommended: false,
217
+ defaultValue: false,
121
218
  },
122
219
  {
123
220
  id: 'always-thinking',
124
221
  label: 'Always enable thinking',
125
222
  description: 'Enable extended thinking by default',
126
- hint: 'Thinking on every turn',
127
- target: { type: 'setting', key: 'alwaysThinkingEnabled', value: true },
128
- defaultEnabled: false,
223
+ hint: 'Forces extended thinking on every turn, including non-complex ones',
224
+ blurb: 'extended thinking always',
225
+ kind: 'boolean',
226
+ target: { type: 'setting', key: 'alwaysThinkingEnabled' },
227
+ onPayload: true,
228
+ recommended: false,
229
+ defaultValue: false,
129
230
  },
130
231
  {
131
232
  id: 'disable-git-instructions',
132
233
  label: 'Disable git instructions',
133
234
  description: 'Remove git workflow instructions from system prompt',
134
- hint: 'Smaller system prompt',
135
- target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS', value: 'true' },
136
- defaultEnabled: false,
235
+ hint: 'Removes git workflow from system prompt — saves tokens in each turn',
236
+ blurb: 'remove git system prompt',
237
+ kind: 'boolean',
238
+ target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS' },
239
+ onPayload: 'true',
240
+ recommended: false,
241
+ defaultValue: false,
137
242
  },
138
243
  // NOTE: DISABLE_COMPACT and DISABLE_AUTOUPDATER intentionally omit the CLAUDE_CODE_ prefix —
139
244
  // these names are defined by upstream Claude Code and must match exactly.
@@ -141,79 +246,707 @@ export const FLAG_REGISTRY = [
141
246
  id: 'disable-compact',
142
247
  label: 'Disable auto-compaction',
143
248
  description: 'Disable automatic context compaction',
144
- hint: 'Keep full context (uses more tokens)',
145
- target: { type: 'env', key: 'DISABLE_COMPACT', value: 'true' },
146
- defaultEnabled: false,
249
+ hint: 'Disables auto-compaction — retains full context at the cost of more tokens',
250
+ blurb: 'retain full context always',
251
+ kind: 'boolean',
252
+ target: { type: 'env', key: 'DISABLE_COMPACT' },
253
+ onPayload: 'true',
254
+ recommended: false,
255
+ defaultValue: false,
147
256
  },
148
257
  {
258
+ // v2.1.223 semantics: disables the 1M-token context window experiment and
259
+ // falls back to the standard context budget for the model.
149
260
  id: 'disable-1m-context',
150
261
  label: 'Disable 1M context window',
151
- description: 'Use standard context window instead of extended 1M',
152
- hint: 'Use smaller context window',
153
- target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT', value: 'true' },
154
- defaultEnabled: false,
262
+ description: 'Disable the 1M-token context window experiment (v2.1.223+)',
263
+ hint: 'Opts out of the 1M context experiment — uses standard context budget',
264
+ blurb: 'use standard context budget',
265
+ kind: 'boolean',
266
+ target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT' },
267
+ onPayload: 'true',
268
+ recommended: false,
269
+ defaultValue: false,
155
270
  },
156
271
  {
157
272
  id: 'disable-autoupdater',
158
273
  label: 'Disable auto-updater',
159
274
  description: 'Prevent automatic update checks',
160
- hint: 'No automatic updates',
161
- target: { type: 'env', key: 'DISABLE_AUTOUPDATER', value: 'true' },
162
- defaultEnabled: false,
275
+ hint: 'Prevents automatic update checks — manage updates manually',
276
+ blurb: 'manual update management',
277
+ kind: 'boolean',
278
+ target: { type: 'env', key: 'DISABLE_AUTOUPDATER' },
279
+ onPayload: 'true',
280
+ recommended: false,
281
+ defaultValue: false,
163
282
  },
164
283
  {
165
284
  id: 'agent-teams',
166
285
  label: 'Agent Teams (experimental)',
167
286
  description: 'Enable Claude Code experimental Agent Teams',
168
- hint: 'Peer agents / teammate mode — experimental',
169
- target: { type: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', value: '1' },
170
- defaultEnabled: false,
287
+ hint: 'Enables peer-agent teammate mode — experimental, may change any release',
288
+ blurb: 'peer-agent teammate mode',
289
+ kind: 'boolean',
290
+ target: { type: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' },
291
+ onPayload: '1',
292
+ recommended: false,
293
+ defaultValue: false,
171
294
  // Note: the legacy `teammateMode:"auto"` settings key is stripped by
172
295
  // src/core/teammate-mode-cleanup.ts during uninstall (stripDevflowTeammateModeFromJson).
173
296
  // The env var above is the only surface managed by FLAG_REGISTRY for this flag.
174
297
  },
298
+ {
299
+ // Upstream: restores Todo/TaskCreate tools removed by default in Opus 4.8+,
300
+ // Sonnet 5+, and Fable 5+. Set to '1' to re-enable.
301
+ id: 'enable-todo-tools',
302
+ label: 'Enable todo/task tools',
303
+ description: 'Restore Todo and TaskCreate tools removed by default in newer models',
304
+ hint: 'Re-enables Todo/TaskCreate tools on Opus 4.8+ / Sonnet 5+ / Fable 5+',
305
+ blurb: 'restore Todo/Task tools',
306
+ kind: 'boolean',
307
+ target: { type: 'env', key: 'CLAUDE_CODE_ENABLE_TODO_TOOLS' },
308
+ onPayload: '1',
309
+ recommended: false,
310
+ defaultValue: false,
311
+ },
312
+ // ── Valued flags (number/enum/string) ────────────────────────────────────
313
+ {
314
+ // Domain: unset by default; set only when users want a non-default spawn depth.
315
+ // upstreamDefault: 3 (recorded for display). PF-023 bounds: max 10.
316
+ id: 'subagent-spawn-depth',
317
+ label: 'Max subagent spawn depth',
318
+ description: 'Maximum depth of nested subagent spawning',
319
+ hint: 'Caps nested spawn depth; upstream default is 3 — raise only when needed',
320
+ blurb: 'nested spawn depth limit',
321
+ kind: 'number',
322
+ target: { type: 'env', key: 'CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH' },
323
+ recommended: false,
324
+ defaultValue: undefined,
325
+ min: 1,
326
+ max: 10, // devflow sanity bound (applies PF-023)
327
+ integer: true,
328
+ upstreamDefault: 3,
329
+ },
330
+ {
331
+ // Phase 0: domain verified small|medium|large|unrestricted from binary
332
+ // (4-value cluster at adjacent string offsets, adjacent to Workflows feature text).
333
+ id: 'workflow-size-guideline',
334
+ label: 'Workflow size guideline',
335
+ description: 'Guide Claude on the expected size of workflow plans',
336
+ hint: 'Hints preferred plan scale: small/medium/large/unrestricted',
337
+ blurb: 'plan scale hint',
338
+ kind: 'enum',
339
+ target: { type: 'setting', key: 'workflowSizeGuideline' },
340
+ values: ['small', 'medium', 'large', 'unrestricted'],
341
+ recommended: false,
342
+ defaultValue: undefined,
343
+ },
344
+ {
345
+ id: 'default-model',
346
+ label: 'Default model',
347
+ description: 'Override the default model for Claude Code',
348
+ hint: 'Sets ANTHROPIC_DEFAULT_MODEL — overrides session-level model selection',
349
+ blurb: 'override default model',
350
+ kind: 'string',
351
+ target: { type: 'env', key: 'ANTHROPIC_DEFAULT_MODEL' },
352
+ recommended: false,
353
+ defaultValue: undefined,
354
+ maxLength: 64,
355
+ },
356
+ {
357
+ // Upstream default: 30 min. 0 = disabled (still ACTIVE — written to env).
358
+ // PF-023 bounds: max 1440 (24h). min 0 (0 = off, explicit value not neutral).
359
+ id: 'goal-checkin-minutes',
360
+ label: 'Goal check-in interval',
361
+ description: 'Interval in minutes for Claude to check in on task goals',
362
+ hint: 'Periodic goal check-ins every N min; 0 = off; upstream default is 30',
363
+ blurb: 'goal check-in interval',
364
+ kind: 'number',
365
+ target: { type: 'env', key: 'CLAUDE_CODE_GOAL_CHECKIN_MINUTES' },
366
+ recommended: false,
367
+ defaultValue: undefined,
368
+ min: 0, // 0 = off (ACTIVE, not neutral — written as "0")
369
+ max: 1440, // devflow sanity bound: 24 hours (applies PF-023)
370
+ integer: true,
371
+ upstreamDefault: 30,
372
+ },
373
+ {
374
+ // Writes as { command: value } per Claude Code spellcheck setting shape.
375
+ id: 'spellcheck',
376
+ label: 'Spellcheck command',
377
+ description: 'Custom spellcheck command for Claude Code',
378
+ hint: 'Sets the external spell-check command (written as {command: ...})',
379
+ blurb: 'external spell-check command',
380
+ kind: 'string',
381
+ target: { type: 'setting', key: 'spellcheck' },
382
+ recommended: false,
383
+ defaultValue: undefined,
384
+ wrapKey: 'command',
385
+ maxLength: 256, // devflow sanity bound (applies PF-023)
386
+ },
387
+ {
388
+ // view-mode folded into the registry; neutralValue 'default' deletes the viewMode key.
389
+ // VIEW_MODES, ViewMode, resolveExistingViewMode, and resolveFinalViewMode remain exported
390
+ // for init.ts and other callers that read/resolve view-mode in the settings pipeline.
391
+ id: 'view-mode',
392
+ label: 'View mode',
393
+ description: 'Interface view mode (default / verbose / focus)',
394
+ hint: "Controls view mode; 'default' removes the key (Claude Code native default)",
395
+ blurb: 'interface view mode',
396
+ kind: 'enum',
397
+ target: { type: 'setting', key: 'viewMode' },
398
+ values: ['default', 'verbose', 'focus'],
399
+ valueHints: {
400
+ default: 'Standard view (no override)',
401
+ verbose: 'Show all tool output and reasoning',
402
+ focus: 'Minimal UI — hides secondary panels',
403
+ },
404
+ neutralValue: 'default',
405
+ recommended: false,
406
+ defaultValue: 'default',
407
+ },
175
408
  ];
409
+ // Pre-built lookup for O(1) flag-by-id access.
410
+ const FLAG_REGISTRY_MAP = new Map(FLAG_REGISTRY.map(f => [f.id, f]));
176
411
  /**
177
- * Return IDs of all flags that are enabled by default.
412
+ * O(1) flag lookup backed by FLAG_REGISTRY_MAP.
413
+ * Returns undefined when the id is not in the registry.
178
414
  */
179
- export function getDefaultFlags() {
180
- return FLAG_REGISTRY.filter(f => f.defaultEnabled).map(f => f.id);
415
+ export function findFlag(id) {
416
+ return FLAG_REGISTRY_MAP.get(id);
181
417
  }
418
+ // ─── Core value helpers ───────────────────────────────────────────────────────
182
419
  /**
183
- * Apply enabled flags to a settings JSON string.
184
- * Sets env vars for env-type flags and top-level keys for setting-type flags.
185
- * Ignores unknown flag IDs (forward-compatible with old manifests).
420
+ * Returns the neutral value for a flag — the value that means "no preference"
421
+ * (applying neutral deletes the target key).
422
+ *
423
+ * - boolean: false (false = off = no key written)
424
+ * - enum: neutralValue if defined, else null
425
+ * - number: null (no number, including 0, is neutral — 0 is ACTIVE)
426
+ * - string: null
186
427
  */
187
- export function applyFlags(settingsJson, flagIds) {
188
- const settings = JSON.parse(settingsJson);
189
- const flagMap = new Map(FLAG_REGISTRY.map(f => [f.id, f]));
190
- for (const id of flagIds) {
191
- const flag = flagMap.get(id);
192
- if (!flag)
428
+ export function neutralValueOf(flag) {
429
+ switch (flag.kind) {
430
+ case 'boolean': return false;
431
+ case 'enum': return flag.neutralValue ?? null;
432
+ case 'number': return null;
433
+ case 'string': return null;
434
+ }
435
+ }
436
+ /**
437
+ * Returns true when `value` is the neutral value for `flag`.
438
+ * null is always neutral. Number 0 is NOT neutral.
439
+ */
440
+ export function isNeutral(flag, value) {
441
+ if (value === null)
442
+ return true;
443
+ return value === neutralValueOf(flag);
444
+ }
445
+ /**
446
+ * Map a record value to a TUI value.
447
+ *
448
+ * viewMode GLUE RULE (PF-017 one-shared-definition corollary): the mapping lives here,
449
+ * next to neutralValueOf — the definition it depends on — not across a module boundary.
450
+ * enum with neutralValue: neutralValue → null in TUI (null is the TUI representation
451
+ * of "use the default"; the key is deleted when persisted).
452
+ * All other values pass through unchanged.
453
+ *
454
+ * Consumers: flags-view/state.ts (buildFlagRows, buildDevflowDefault, collectFlagRecord).
455
+ */
456
+ export function recordToTui(flag, v) {
457
+ if (v === null)
458
+ return null;
459
+ if (flag.kind === 'enum' && flag.neutralValue !== undefined) {
460
+ if (v === flag.neutralValue)
461
+ return null;
462
+ }
463
+ return v;
464
+ }
465
+ /**
466
+ * Map a TUI value back to a record value.
467
+ *
468
+ * viewMode GLUE RULE (PF-017 one-shared-definition corollary): inverse of recordToTui,
469
+ * co-located with that function so the round-trip contract is auditable in one place.
470
+ * enum with neutralValue: null → neutralValue (e.g. 'default').
471
+ * All other values pass through unchanged.
472
+ *
473
+ * Consumers: flags-view/state.ts (collectFlagRecord).
474
+ */
475
+ export function tuiToRecord(flag, v) {
476
+ if (v === null && flag.kind === 'enum' && flag.neutralValue !== undefined) {
477
+ return flag.neutralValue;
478
+ }
479
+ return v;
480
+ }
481
+ /**
482
+ * Validate and coerce `raw` to a safe value for `flag` at the sink.
483
+ * Returns null when the value is invalid (hostile-value defence — applies PF-023).
484
+ *
485
+ * Number invariants: finite, within [min, max], integer when required.
486
+ * String invariants: within maxLength, no control characters.
487
+ * Enum invariants: value must be in the declared values array.
488
+ * Boolean invariants: must be a boolean.
489
+ */
490
+ export function coerceFlagValue(flag, raw) {
491
+ if (raw === null || raw === undefined)
492
+ return null;
493
+ switch (flag.kind) {
494
+ case 'boolean': {
495
+ if (typeof raw !== 'boolean')
496
+ return null;
497
+ return raw;
498
+ }
499
+ case 'enum': {
500
+ if (typeof raw !== 'string')
501
+ return null;
502
+ if (!flag.values.includes(raw))
503
+ return null;
504
+ return raw;
505
+ }
506
+ case 'number': {
507
+ if (typeof raw !== 'number')
508
+ return null;
509
+ if (!Number.isFinite(raw))
510
+ return null; // rejects Infinity, NaN, 1e309
511
+ if (flag.min !== undefined && raw < flag.min)
512
+ return null;
513
+ if (flag.max !== undefined && raw > flag.max)
514
+ return null;
515
+ if (flag.integer === true && !Number.isInteger(raw))
516
+ return null;
517
+ return raw;
518
+ }
519
+ case 'string': {
520
+ if (typeof raw !== 'string')
521
+ return null;
522
+ // Empty string is UNSET, never an active value — caller should pass null for unset.
523
+ if (raw === '')
524
+ return null;
525
+ if (flag.maxLength !== undefined && raw.length > flag.maxLength)
526
+ return null;
527
+ // Reject ASCII control chars except \t (horizontal tab is benign in commands).
528
+ // LF (\x0a) MUST be rejected: `spellcheck` is executed as a shell command, where
529
+ // a newline is a statement separator, and the --status table is line-oriented.
530
+ // The range \x0a-\x1f covers LF through US, with \x09 (TAB) as the sole omission.
531
+ if (/[\x00-\x08\x0a-\x1f\x7f]/.test(raw))
532
+ return null;
533
+ return raw;
534
+ }
535
+ }
536
+ }
537
+ /**
538
+ * Parse a CLI text input to a FlagsRecordValue.
539
+ * 'unset' (literal) → null for any flag.
540
+ *
541
+ * Number branch uses strict decimal grammar (applies PF-023 — invariant at the sink
542
+ * every caller reaches, not per-caller): rejects empty, padded, hex, exponent,
543
+ * and leading-zero forms. Equivalent to the TUI's strict parsing so both entry
544
+ * points share one grammar.
545
+ *
546
+ * String branch: empty string → null (empty is UNSET, not an active value).
547
+ */
548
+ export function parseFlagValueInput(flag, text) {
549
+ if (text === 'unset')
550
+ return null;
551
+ switch (flag.kind) {
552
+ case 'boolean': {
553
+ if (text === 'true')
554
+ return true;
555
+ if (text === 'false')
556
+ return false;
557
+ return null;
558
+ }
559
+ case 'enum':
560
+ return coerceFlagValue(flag, text);
561
+ case 'number': {
562
+ // Strict decimal grammar: reject empty, padded, hex, exponent, and leading zeros.
563
+ // Number('') === 0, Number(' 3 ') === 3, Number('0x5') === 5, Number('1e1') === 10 —
564
+ // all would pass bare Number() but violate the strict grammar contract.
565
+ if (text === '' || text !== text.trim())
566
+ return null;
567
+ if (!/^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text))
568
+ return null;
569
+ return coerceFlagValue(flag, Number(text));
570
+ }
571
+ case 'string':
572
+ // Empty string → null (empty is UNSET); coerceFlagValue handles the rest.
573
+ return coerceFlagValue(flag, text);
574
+ }
575
+ }
576
+ /**
577
+ * Returns a human-readable kind label for a flag — used by --list output.
578
+ *
579
+ * Exhaustive switch (no default): TypeScript narrows on `flag.kind` so
580
+ * each branch sees the narrowed subtype directly.
581
+ *
582
+ * Output examples:
583
+ * boolean → 'boolean'
584
+ * enum [small|medium|large|…] → 'enum [small|medium|large|…]'
585
+ * number min=1 max=100 integer → 'number min=1 max=100 integer'
586
+ * string maxLen=64 → 'string maxLen=64'
587
+ */
588
+ export function describeFlagKind(flag) {
589
+ switch (flag.kind) {
590
+ case 'boolean':
591
+ return 'boolean';
592
+ case 'enum':
593
+ return `enum [${flag.values.join('|')}]`;
594
+ case 'number': {
595
+ const parts = [];
596
+ if (flag.min !== undefined)
597
+ parts.push(`min=${flag.min}`);
598
+ if (flag.max !== undefined)
599
+ parts.push(`max=${flag.max}`);
600
+ if (flag.integer)
601
+ parts.push('integer');
602
+ return `number${parts.length ? ' ' + parts.join(' ') : ''}`;
603
+ }
604
+ case 'string':
605
+ return `string${flag.maxLength !== undefined ? ` maxLen=${flag.maxLength}` : ''}`;
606
+ }
607
+ }
608
+ /**
609
+ * Returns the expected-input hint shown by --set when a value is invalid.
610
+ *
611
+ * Exhaustive switch: TypeScript narrows each arm directly.
612
+ *
613
+ * Output examples:
614
+ * boolean → 'true|false|unset'
615
+ * enum → 'small|medium|large|unrestricted|unset'
616
+ * number → 'a valid number value or unset'
617
+ * string → 'a valid string value or unset'
618
+ */
619
+ export function expectedInputFor(flag) {
620
+ switch (flag.kind) {
621
+ case 'boolean':
622
+ return 'true|false|unset';
623
+ case 'enum':
624
+ return `${flag.values.join('|')}|unset`;
625
+ case 'number':
626
+ return 'a valid number value or unset';
627
+ case 'string':
628
+ return 'a valid string value or unset';
629
+ }
630
+ }
631
+ /**
632
+ * Returns the effective display for a flag value — the single source of truth
633
+ * for every render site (TUI cell, --status rows, --list default label,
634
+ * --enable/--disable confirmation text).
635
+ *
636
+ * Never returns 'unset' — always shows what the flag effectively does:
637
+ * boolean true → { text: 'on', isDefault: false }
638
+ * boolean false/null → { text: 'off', isDefault: true }
639
+ * enum active value → { text: value, isDefault: false }
640
+ * enum neutral/null → { text: neutralValue ?? '—', isDefault: true }
641
+ * number active value → { text: String(value), isDefault: false }
642
+ * number null → { text: String(defaultValue ?? upstreamDefault), isDefault: true }
643
+ * or { text: '—', isDefault: true } when no default exists
644
+ * string active value → { text: value, isDefault: false }
645
+ * string null → { text: '—', isDefault: true }
646
+ *
647
+ * D-EFFDV: one-definition seam consumed by formatFlagValue, render.ts formatValue,
648
+ * formatStatusRows (Site D), and handleList defaultLabel (Site C).
649
+ * All display sites must route through here — never re-derive independently.
650
+ */
651
+ export function effectiveDisplay(flag, value) {
652
+ // Boolean: true = 'on' (active), false/null = 'off' (neutral but meaningful)
653
+ if (flag.kind === 'boolean') {
654
+ const active = value === true;
655
+ return { text: active ? 'on' : 'off', isDefault: !active };
656
+ }
657
+ // Active non-null non-neutral value for enum/number/string
658
+ if (value !== null && !isNeutral(flag, value)) {
659
+ return { text: String(value), isDefault: false };
660
+ }
661
+ // Neutral or null: show what the flag effectively defaults to
662
+ switch (flag.kind) {
663
+ case 'enum': {
664
+ const neutral = flag.neutralValue ?? '—';
665
+ return { text: neutral, isDefault: true };
666
+ }
667
+ case 'number': {
668
+ const def = flag.defaultValue ?? flag.upstreamDefault;
669
+ return { text: def !== undefined ? String(def) : '—', isDefault: true };
670
+ }
671
+ case 'string':
672
+ return { text: '—', isDefault: true };
673
+ }
674
+ }
675
+ /**
676
+ * Format a flag value for display (CLI output, confirmation messages).
677
+ *
678
+ * Delegates to effectiveDisplay — see its JSDoc for the full vocabulary.
679
+ * D-EFFDV: one definition, all sites route through effectiveDisplay.
680
+ *
681
+ * NOTE: --set confirmation for an explicit 'unset' input should special-case
682
+ * null → literal 'unset' at the call site, since the user named it explicitly.
683
+ */
684
+ export function formatFlagValue(flag, value) {
685
+ return effectiveDisplay(flag, value).text;
686
+ }
687
+ /**
688
+ * Count flags in `record` that have active (non-neutral) values.
689
+ * Unknown IDs are counted if their value is truthy.
690
+ */
691
+ export function countActiveFlags(record) {
692
+ let count = 0;
693
+ for (const [id, value] of Object.entries(record)) {
694
+ if (value === null)
193
695
  continue;
194
- if (flag.target.type === 'env') {
195
- settings.env ??= {};
196
- settings.env[flag.target.key] = flag.target.value;
696
+ const flag = FLAG_REGISTRY_MAP.get(id);
697
+ if (flag) {
698
+ if (!isNeutral(flag, value))
699
+ count++;
700
+ }
701
+ else if (value) {
702
+ // Unknown flag ID: count if truthy
703
+ count++;
704
+ }
705
+ }
706
+ return count;
707
+ }
708
+ /**
709
+ * Read the view-mode from a FlagsRecord.
710
+ * Returns 'default' when the entry is absent, null, or unrecognised.
711
+ */
712
+ export function readViewMode(record) {
713
+ const v = record['view-mode'];
714
+ if (typeof v === 'string' && VIEW_MODES.includes(v)) {
715
+ return v;
716
+ }
717
+ return 'default';
718
+ }
719
+ /**
720
+ * Sanitize a FlagsRecord by coercing each known flag's value through
721
+ * coerceFlagValue.
722
+ *
723
+ * Known flag IDs (applies ADR-014 key-presence semantics):
724
+ * - explicit null input → kept as null (deliberately unset)
725
+ * - valid non-null input → kept as coerced value
726
+ * - invalid non-null input → KEY DROPPED (absent = adopt default on next init,
727
+ * which is safer than writing null = "deliberately unset" for a corrupt value)
728
+ *
729
+ * Unknown flag IDs (forward-compat):
730
+ * - primitive values (boolean, number, string, null) → kept as-is
731
+ * - non-primitive values (objects, arrays) → DROPPED to avoid laundering
732
+ * untrusted shapes into FlagsRecordValue (applies PF-023)
733
+ *
734
+ * D39: `__proto__`, `constructor`, `prototype` are always skipped.
735
+ */
736
+ export function sanitizeFlagsRecord(record) {
737
+ const result = {};
738
+ for (const [id, value] of Object.entries(record)) {
739
+ // D39: prototype pollution guard — skip dangerous own-property names that
740
+ // would invoke [[Set]] accessors on the result object and mutate its prototype.
741
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype')
742
+ continue;
743
+ const flag = FLAG_REGISTRY_MAP.get(id);
744
+ if (flag) {
745
+ if (value === null) {
746
+ // Explicit null = deliberately unset: preserve key-presence semantics.
747
+ result[id] = null;
748
+ }
749
+ else {
750
+ const coerced = coerceFlagValue(flag, value);
751
+ if (coerced !== null) {
752
+ result[id] = coerced;
753
+ }
754
+ // else: invalid non-null value → DROP the key so the flag is re-adopted
755
+ // on next init from registry defaults (safer than writing null = "unset").
756
+ }
757
+ }
758
+ else {
759
+ // Unknown id: forward-compat pass-through for primitive/null values only.
760
+ // Non-primitive values (objects, arrays) are dropped — laundering an
761
+ // arbitrary object into FlagsRecordValue violates the type contract.
762
+ if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
763
+ result[id] = value;
764
+ }
765
+ }
766
+ }
767
+ return result;
768
+ }
769
+ // ─── Record builders ──────────────────────────────────────────────────────────
770
+ /**
771
+ * Per-kind default-value rule (single authoritative source — CONS-M2).
772
+ *
773
+ * - boolean: flag.defaultValue (always a boolean — never collapses to null)
774
+ * - enum / number / string: flag.defaultValue ?? null
775
+ * (undefined defaultValue → null = adopt-on-next-init semantics)
776
+ *
777
+ * Call sites: getDefaultFlagsRecord, resolveSeedFlags (init-seed.ts),
778
+ * buildDevflowDefault (flags-view/state.ts). Adding a fifth kind or changing
779
+ * the null-collapse rule requires updating only this function.
780
+ */
781
+ export function defaultValueOf(flag) {
782
+ return flag.kind === 'boolean' ? flag.defaultValue : (flag.defaultValue ?? null);
783
+ }
784
+ /**
785
+ * Return a FlagsRecord with every registered flag set to its defaultValue.
786
+ * Flags with undefined defaultValue get null.
787
+ * This record has an entry for EVERY flag — use it for initial seeding.
788
+ */
789
+ export function getDefaultFlagsRecord() {
790
+ return Object.fromEntries(FLAG_REGISTRY.map(f => [f.id, defaultValueOf(f)]));
791
+ }
792
+ // ─── Migration helper ─────────────────────────────────────────────────────────
793
+ /**
794
+ * Migrate a legacy (string-array) enabled-flags manifest to a typed FlagsRecord.
795
+ * Called by manifest.ts self-healing when it encounters an old string-array manifest.
796
+ *
797
+ * Contract (applies ADR-014 transition semantics):
798
+ * - knownIds defined → knownSet = knownIds ∪ enabledIds
799
+ * - knownIds undefined → knownSet = full current registry ∪ enabledIds
800
+ * (pre-knownFlags manifests: all flags known, so adopt-nothing is expressed
801
+ * as value = enabledIds.includes(id) rather than absent entry)
802
+ * - Boolean registry flags in knownSet → value = enabledIds.includes(id)
803
+ * - Registry flags NOT in knownSet → NO entry (adopted on next seed)
804
+ * - Unknown enabled IDs (not in registry) → `true` preserved
805
+ * - viewMode fold: 'view-mode' = legacyViewMode ?? 'default'
806
+ */
807
+ export function migrateLegacyFlagsToRecord(enabledIds, knownIds, legacyViewMode) {
808
+ const enabledSet = new Set(enabledIds);
809
+ const knownSet = knownIds !== undefined
810
+ ? new Set([...knownIds, ...enabledIds])
811
+ : new Set([...FLAG_REGISTRY.map(f => f.id), ...enabledIds]);
812
+ const result = {};
813
+ for (const flag of FLAG_REGISTRY) {
814
+ // view-mode is handled separately at the end
815
+ if (flag.id === 'view-mode')
816
+ continue;
817
+ if (!knownSet.has(flag.id)) {
818
+ // Not known at last install → NO entry (will be adopted on next seed)
819
+ continue;
820
+ }
821
+ if (flag.kind === 'boolean') {
822
+ result[flag.id] = enabledSet.has(flag.id);
823
+ }
824
+ else {
825
+ // Valued flags: legacy string arrays never contain them; null = neutral
826
+ result[flag.id] = null;
827
+ }
828
+ }
829
+ // Unknown enabled IDs (not in any registry) preserved as true
830
+ for (const id of enabledIds) {
831
+ if (!FLAG_REGISTRY_MAP.has(id)) {
832
+ result[id] = true;
833
+ }
834
+ }
835
+ // viewMode fold: always written so the view-mode entry is explicit
836
+ result['view-mode'] = legacyViewMode ?? 'default';
837
+ return result;
838
+ }
839
+ // ─── Apply / Strip ────────────────────────────────────────────────────────────
840
+ /**
841
+ * Return `v` as a `Record<string, unknown>` only when it is a plain object.
842
+ * Returns undefined for arrays, null, or non-objects.
843
+ *
844
+ * Used as a guard at every `settings.env` access point so that a malformed
845
+ * `"env": []` in settings.json cannot cause `Object.keys([]).length === 0`
846
+ * to delete the entire env key, losing user-set env vars (applies TS-M3).
847
+ */
848
+ function asPlainObject(v) {
849
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
850
+ ? v
851
+ : undefined;
852
+ }
853
+ /** Compute the value to write to settings.json for an active flag. */
854
+ function buildPayload(flag, value) {
855
+ switch (flag.kind) {
856
+ case 'boolean':
857
+ return flag.onPayload;
858
+ case 'enum':
859
+ return value;
860
+ case 'number':
861
+ // Env targets receive string values; setting targets receive numbers.
862
+ return flag.target.type === 'env' ? String(value) : value;
863
+ case 'string': {
864
+ const s = value;
865
+ return flag.wrapKey ? { [flag.wrapKey]: s } : s;
866
+ }
867
+ }
868
+ }
869
+ /**
870
+ * Apply a FlagsRecord to a settings JSON string.
871
+ *
872
+ * - Unknown flag IDs are skipped (forward-compatible with future flags).
873
+ * - `coerceFlagValue` is called at the sink before applying (applies PF-023).
874
+ * - Neutral values delete their target key.
875
+ * - Env payloads for number flags are stringified ('40', never 40).
876
+ * - Setting payloads for string flags with wrapKey are shaped ({ command: v }).
877
+ * - env object is created on demand; deleted when it becomes empty.
878
+ * - `__proto__`, `constructor`, `prototype` keys are silently skipped.
879
+ */
880
+ export function applyFlags(settingsJson, flags) {
881
+ // REL-M2 sink guard (applies PF-023): a non-plain-object root (null, array, scalar)
882
+ // would cause a silent no-op or a confusing TypeError deep inside the loop.
883
+ // Throw early with a clear message so every caller path is self-guarding.
884
+ const root = JSON.parse(settingsJson);
885
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
886
+ throw new Error('applyFlags: settings.json root must be a plain object');
887
+ }
888
+ const settings = root;
889
+ for (const [id, value] of Object.entries(flags)) {
890
+ // Prototype pollution guard
891
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype')
892
+ continue;
893
+ const flag = FLAG_REGISTRY_MAP.get(id);
894
+ if (!flag)
895
+ continue; // unknown id — skip for forward compat
896
+ // Coerce at the sink (applies PF-023: validate at the convergence point)
897
+ const safe = coerceFlagValue(flag, value);
898
+ if (isNeutral(flag, safe)) {
899
+ // Neutral → delete the target key
900
+ if (flag.target.type === 'env') {
901
+ // asPlainObject guard: "env": [] must not delete a user's env var (applies TS-M3)
902
+ const env = asPlainObject(settings.env);
903
+ if (env)
904
+ delete env[flag.target.key];
905
+ }
906
+ else {
907
+ delete settings[flag.target.key];
908
+ }
197
909
  }
198
910
  else {
199
- settings[flag.target.key] = flag.target.value;
911
+ const payload = buildPayload(flag, safe);
912
+ if (flag.target.type === 'env') {
913
+ if (!asPlainObject(settings.env)) {
914
+ settings.env = {};
915
+ }
916
+ settings.env[flag.target.key] = payload;
917
+ }
918
+ else {
919
+ settings[flag.target.key] = payload;
920
+ }
200
921
  }
201
922
  }
923
+ // Clean up empty env object; asPlainObject guard avoids matching "env": []
924
+ const env = asPlainObject(settings.env);
925
+ if (env && Object.keys(env).length === 0) {
926
+ delete settings.env;
927
+ }
202
928
  return JSON.stringify(settings, null, 2) + '\n';
203
929
  }
204
930
  /**
205
931
  * Strip all flag-managed keys from a settings JSON string.
206
- * Removes env vars and top-level settings controlled by the flag registry.
207
- * Cleans up empty env object when last entry is removed.
932
+ * Registry-driven unconditional delete. Now covers viewMode (via view-mode
933
+ * registry entry) and object-valued settings (spellcheck → key deleted).
934
+ * Cleans up empty env object. Strip-then-apply idempotence preserved (INV-1).
208
935
  */
209
936
  export function stripFlags(settingsJson) {
210
- const settings = JSON.parse(settingsJson);
211
- const env = settings.env;
937
+ // REL-M2 sink guard (applies PF-023): mirror of applyFlags — throw early on a
938
+ // non-plain-object root so every caller path is self-guarding.
939
+ const root = JSON.parse(settingsJson);
940
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
941
+ throw new Error('stripFlags: settings.json root must be a plain object');
942
+ }
943
+ const settings = root;
944
+ // asPlainObject guard: "env": [] must not have its keys iterated as an object (applies TS-M3)
945
+ const env = asPlainObject(settings.env);
212
946
  for (const flag of FLAG_REGISTRY) {
213
947
  if (flag.target.type === 'env') {
214
- if (env) {
948
+ if (env)
215
949
  delete env[flag.target.key];
216
- }
217
950
  }
218
951
  else {
219
952
  delete settings[flag.target.key];
@@ -224,33 +957,10 @@ export function stripFlags(settingsJson) {
224
957
  }
225
958
  return JSON.stringify(settings, null, 2) + '\n';
226
959
  }
960
+ // ─── viewMode helpers ─────────────────────────────────────────────────────────
227
961
  const VIEW_MODE_KEY = 'viewMode';
228
962
  /** All valid view mode values. Used for validation at manifest read boundaries. */
229
963
  export const VIEW_MODES = ['default', 'verbose', 'focus'];
230
- /**
231
- * Apply a view mode to a settings JSON string.
232
- * 'default' removes the viewMode key (Claude Code default behaviour);
233
- * 'verbose' and 'focus' set the key explicitly.
234
- */
235
- export function applyViewMode(settingsJson, mode) {
236
- const settings = JSON.parse(settingsJson);
237
- if (mode === 'default') {
238
- delete settings[VIEW_MODE_KEY];
239
- }
240
- else {
241
- settings[VIEW_MODE_KEY] = mode;
242
- }
243
- return JSON.stringify(settings, null, 2) + '\n';
244
- }
245
- /**
246
- * Strip the viewMode key from a settings JSON string.
247
- * Used during uninstall / flag strip to restore Claude Code defaults.
248
- */
249
- export function stripViewMode(settingsJson) {
250
- const settings = JSON.parse(settingsJson);
251
- delete settings[VIEW_MODE_KEY];
252
- return JSON.stringify(settings, null, 2) + '\n';
253
- }
254
964
  /**
255
965
  * Extract the non-default view mode from a settings JSON string.
256
966
  *
@@ -283,20 +993,11 @@ export function resolveExistingViewMode(settingsJson) {
283
993
  }
284
994
  /**
285
995
  * Resolve the final view mode to write, combining an existing settings value,
286
- * an init-prompt-selected value, and whether the selection was explicit (via
287
- * CLI flag) or implicit (prompt default / recommended path).
288
- *
289
- * @param current - Existing viewMode from settings.json (resolveExistingViewMode).
290
- * undefined means no opinion in the current settings.
291
- * @param selected - What the init prompt (or recommended path) would use.
292
- * @param explicit - true when the user made an explicit interactive selection in
293
- * the Advanced init prompt, or when --reset was passed (which
294
- * forces viewMode back to 'default' and sets explicit=true so
295
- * that 'default' wins over any externally-set value).
996
+ * an init-prompt-selected value, and whether the selection was explicit.
296
997
  *
297
998
  * Rules:
298
999
  * 1. explicit ⇒ selected wins (user intent is unambiguous, even 'default')
299
- * 2. non-default current ⇒ current wins (preserve externally-set mode, e.g. /focus)
1000
+ * 2. non-default current ⇒ current wins (preserve externally-set mode)
300
1001
  * 3. else ⇒ selected
301
1002
  */
302
1003
  export function resolveFinalViewMode(current, selected, explicit) {
@@ -306,4 +1007,118 @@ export function resolveFinalViewMode(current, selected, explicit) {
306
1007
  return current;
307
1008
  return selected;
308
1009
  }
1010
+ // ─── Fold-before-strip pipeline ───────────────────────────────────────────────
1011
+ /**
1012
+ * Fold-before-strip pipeline — the single authoritative entry point for all
1013
+ * settings.json mutation paths (applies PF-015, PF-017, ADR-014).
1014
+ *
1015
+ * Both `init.ts` and `persistFlagConfig` (flags.ts) MUST call this instead of
1016
+ * invoking `stripFlags` + `applyFlags` directly; the invariant lives in the
1017
+ * pipeline, not at call sites.
1018
+ *
1019
+ * Fold semantics (D15-adopt):
1020
+ *
1021
+ * view-mode (Step 1): resolved via `resolveFinalViewMode` so an externally-set
1022
+ * `/focus` survives unless `viewModeExplicit` is true.
1023
+ *
1024
+ * Valued flags — enum/number/string, excluding view-mode (Step 2):
1025
+ * The "claimed" set is determined by `opts.ownedRecord`:
1026
+ * - `undefined` → use `record` itself (persistFlagConfig path — the manifest
1027
+ * record IS what devflow claims)
1028
+ * - `null` → nothing previously owned (fresh install)
1029
+ * - `FlagsRecord`→ the original manifest flags BEFORE seeding (init path)
1030
+ *
1031
+ * A flag is "claimed" when it is present and non-null in the claimed set.
1032
+ * Claimed: record value wins (devflow previously set this value).
1033
+ * Unclaimed: fold from settings — if the user has a value in settings.json,
1034
+ * adopt it into the record (ADR-014 adoption, devflow takes ownership).
1035
+ *
1036
+ * Boolean flags: never folded — on/off is always record-driven.
1037
+ *
1038
+ * The fold MUST run on pre-strip content — `stripFlags` removes the target
1039
+ * keys, making any fold after strip vacuous.
1040
+ *
1041
+ * Uninstall note: `src/cli/commands/uninstall.ts` calls `stripFlags` directly
1042
+ * with no record argument, preserving its full-sweep semantics. Do not change.
1043
+ *
1044
+ * Pure function: no I/O.
1045
+ *
1046
+ * @param settingsJson Current settings.json content (pre-strip)
1047
+ * @param record FlagsRecord to fold into and apply
1048
+ * @param opts.viewModeExplicit true when the caller explicitly selected a view
1049
+ * mode (TUI row changed or `--set view-mode=...` passed)
1050
+ * @param opts.ownedRecord Prior ownership set; see semantics above.
1051
+ * Init path: `existingManifest?.features.flags ?? null`.
1052
+ * persistFlagConfig path: omit (undefined).
1053
+ * @returns `{ settings: updated JSON string, record: folded FlagsRecord }`
1054
+ */
1055
+ export function convergeFlagsIntoSettings(settingsJson, record, opts) {
1056
+ // ── Step 1: fold view-mode (must read pre-strip) ──────────────────────────
1057
+ // PF-015: resolveExistingViewMode reads the viewMode key. stripFlags removes
1058
+ // it as part of the view-mode registry entry. Reading after strip silently
1059
+ // reverts an externally-set /focus.
1060
+ const folded = {
1061
+ ...record,
1062
+ 'view-mode': resolveFinalViewMode(resolveExistingViewMode(settingsJson), readViewMode(record), opts.viewModeExplicit),
1063
+ };
1064
+ // ── Step 2: fold existing values for valued flags (pre-strip) ────────────
1065
+ // D15-adopt: for unclaimed valued flags, read the current settings value and
1066
+ // adopt it into the record. Claimed flags (previously set by devflow) keep
1067
+ // their record value; boolean flags are never folded.
1068
+ //
1069
+ // "Claimed" is determined by opts.ownedRecord:
1070
+ // undefined → use `record` (persistFlagConfig: manifest record = owned set)
1071
+ // null → nothing claimed (fresh install)
1072
+ // FlagsRecord → original manifest flags before seeding (init path)
1073
+ const claimedIn = opts.ownedRecord !== undefined ? opts.ownedRecord : record;
1074
+ let parsed;
1075
+ try {
1076
+ parsed = JSON.parse(settingsJson);
1077
+ }
1078
+ catch {
1079
+ parsed = {};
1080
+ }
1081
+ const env = asPlainObject(parsed.env);
1082
+ for (const flag of FLAG_REGISTRY) {
1083
+ if (flag.kind === 'boolean')
1084
+ continue; // boolean flags: record-driven only
1085
+ if (flag.id === 'view-mode')
1086
+ continue; // already handled above
1087
+ // Check whether devflow previously owned this flag's key.
1088
+ // Any presence in claimedIn — including null (explicitly unset) — means
1089
+ // devflow owns the slot; the record value (or its absence) wins over settings.
1090
+ // Absence from claimedIn means devflow never wrote it → fold from settings.
1091
+ const previouslyOwned = claimedIn !== null &&
1092
+ Object.prototype.hasOwnProperty.call(claimedIn, flag.id);
1093
+ if (previouslyOwned)
1094
+ continue;
1095
+ // Read the raw value from settings.json (before strip removes it)
1096
+ const rawVal = flag.target.type === 'env'
1097
+ ? env?.[flag.target.key]
1098
+ : parsed[flag.target.key];
1099
+ if (rawVal === undefined)
1100
+ continue;
1101
+ // Unwrap wrapKey-shaped values (e.g., spellcheck: { command: 'hunspell' } → 'hunspell')
1102
+ let toCoerce = rawVal;
1103
+ if (flag.kind === 'string' && flag.wrapKey !== undefined) {
1104
+ const obj = asPlainObject(rawVal);
1105
+ toCoerce = obj !== undefined ? obj[flag.wrapKey] : undefined;
1106
+ }
1107
+ if (toCoerce === undefined)
1108
+ continue;
1109
+ // Env vars store numbers as strings ('8') — convert to number for coercion
1110
+ if (flag.kind === 'number' && typeof toCoerce === 'string') {
1111
+ const n = Number(toCoerce);
1112
+ toCoerce = Number.isFinite(n) ? n : toCoerce;
1113
+ }
1114
+ const coerced = coerceFlagValue(flag, toCoerce);
1115
+ if (coerced !== null) {
1116
+ folded[flag.id] = coerced;
1117
+ }
1118
+ }
1119
+ // ── Step 3: strip all managed keys, then apply the folded record ──────────
1120
+ const stripped = stripFlags(settingsJson);
1121
+ const settings = applyFlags(stripped, folded);
1122
+ return { settings, record: folded };
1123
+ }
309
1124
  //# sourceMappingURL=flags.js.map