devflow-kit 2.3.0 → 2.4.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.
@@ -15,8 +15,8 @@ import { removeCaptureHooks } from './capture.js';
15
15
  import { removeDreamHook } from './legacy-hooks.js';
16
16
  import { removeHudStatusLine } from './hud.js';
17
17
  import { removeContextHook } from './context.js';
18
- import { applyDisableToSettings } from './proxy.js';
19
- import { readProxyState, DEFAULT_PROXY_PORT } from '../../core/proxy-state.js';
18
+ import { applyProxyTeardownToSettings } from './proxy.js';
19
+ import { readProxyState, proxyJsonExists } from '../../core/proxy-state.js';
20
20
  import { hudCacheDir } from '../../core/cache.js';
21
21
  import { revertExternalAgents } from '../../core/agent-models.js';
22
22
  import { detectShell, getProfilePath } from '../../core/safe-delete.js';
@@ -768,14 +768,16 @@ export async function runCleanupPhase(opts) {
768
768
  settingsContent = stripFlags(settingsContent); // also strips viewMode via view-mode registry entry
769
769
  settingsContent = stripDevflowTeammateModeFromJson(settingsContent);
770
770
  // Remove proxy hooks and ANTHROPIC_BASE_URL env in a single parse-mutate-serialize pass.
771
- // REG-1: scope the URL strip to the port Devflow manages — use the pre-captured port
772
- // (from opts.managedProxyPorts) because proxy.json is already deleted by
773
- // removeDevFlowInstallArtifacts before this phase runs. A user's own localhost
774
- // gateway on any other port is left in settings untouched.
771
+ // D-STRIP-1: an absent entry in managedProxyPorts means proxy.json did not exist
772
+ // before artifact removal — no evidence Devflow ever wrote the env vars, so
773
+ // applyProxyTeardownToSettings removes only our hooks and leaves the user's
774
+ // ANTHROPIC_BASE_URL / CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT alone.
775
+ // Hook removal is never gated: our hooks must not survive an uninstall.
776
+ // REG-1: the port-scoped URL strip protects any foreign localhost gateway on a
777
+ // different port — the managedPort value is from the pre-captured proxy.json port.
775
778
  {
776
- const managedPort = opts.managedProxyPorts?.get(scope) ?? DEFAULT_PROXY_PORT;
777
779
  const parsedSettings = JSON.parse(settingsContent);
778
- applyDisableToSettings(parsedSettings, managedPort);
780
+ applyProxyTeardownToSettings(parsedSettings, opts.managedProxyPorts?.get(scope));
779
781
  settingsContent = JSON.stringify(parsedSettings, null, 2) + '\n';
780
782
  }
781
783
  if (settingsContent !== originalContent) {
@@ -977,16 +979,24 @@ export const uninstallCommand = new Command('uninstall')
977
979
  // Pre-capture managed proxy ports for each scope BEFORE artifact removal.
978
980
  // proxy.json is deleted by removeDevFlowInstallArtifacts (inside runFullPhaseForScope),
979
981
  // so reading it after the full phase would always yield DEFAULT_PROXY_PORT. (F6)
982
+ //
983
+ // D-STRIP-1: only populate managedProxyPorts when proxy.json exists — evidence that
984
+ // Devflow has managed the proxy on this machine. runCleanupPhase gates the
985
+ // applyProxyTeardownToSettings call on the port being present in the map, so when the file
986
+ // is absent (never managed), no env strip is attempted and a user's own gateway vars
987
+ // are left untouched.
980
988
  const managedProxyPorts = new Map();
981
989
  if (!isSelectiveUninstall) {
982
990
  for (const scope of scopesToUninstall) {
983
991
  try {
984
992
  const paths = await getInstallationPaths(scope);
985
- const proxyState = await readProxyState(paths.devflowDir);
986
- if (proxyState.ok)
987
- managedProxyPorts.set(scope, proxyState.value.port);
993
+ if (await proxyJsonExists(paths.devflowDir)) {
994
+ const proxyState = await readProxyState(paths.devflowDir);
995
+ if (proxyState.ok)
996
+ managedProxyPorts.set(scope, proxyState.value.port);
997
+ }
988
998
  }
989
- catch { /* non-fatal: if we can't read, we fall back to DEFAULT_PROXY_PORT */ }
999
+ catch { /* non-fatal */ }
990
1000
  }
991
1001
  }
992
1002
  // Uninstall from each scope
@@ -11,6 +11,17 @@
11
11
  * point every caller reaches). applyFlags(settingsJson, FlagsRecord) is the
12
12
  * sole API; init.ts works directly with FlagsRecord (no legacy string[] bridge).
13
13
  */
14
+ import { isDeepStrictEqual } from 'node:util';
15
+ /**
16
+ * Type predicate that narrows `ClaudeCodeFlag` to `EnvBooleanFlagDef`.
17
+ * Narrows `f.onPayload` to `string` so callers writing to the env string-map
18
+ * avoid an `unknown` hop without an unsafe cast.
19
+ *
20
+ * Use in `buildPayload` and `applyFlags` only where the string type is load-bearing.
21
+ */
22
+ export function isEnvBooleanFlag(f) {
23
+ return f.kind === 'boolean' && f.target.type === 'env';
24
+ }
14
25
  // ─── Registry ─────────────────────────────────────────────────────────────────
15
26
  // Phase 0 probe findings: see docs/reference/claude-code-flags-probe.md
16
27
  /**
@@ -309,6 +320,24 @@ export const FLAG_REGISTRY = [
309
320
  recommended: false,
310
321
  defaultValue: false,
311
322
  },
323
+ {
324
+ // D27: gates Claude Code's AI-attribution injection into git commits and PRs.
325
+ // When true, writes {"commit":"","pr":""} to settings.json which suppresses attribution.
326
+ // When false/null (neutral), removes the key ONLY when the current value is the exact
327
+ // devflow-managed shape (settingDeleteGuard). A custom attribution value (e.g. an org
328
+ // name) is never deleted — shape guard prevents erasure (D-ATTR-GUARD).
329
+ id: 'suppress-attribution',
330
+ label: 'Suppress AI attribution',
331
+ description: 'Remove AI-attribution labels from git commits and pull requests',
332
+ hint: 'Suppresses Claude attribution labels in git commits and pull requests',
333
+ blurb: 'hide AI attribution labels',
334
+ kind: 'boolean',
335
+ target: { type: 'setting', key: 'attribution' },
336
+ onPayload: { commit: '', pr: '' },
337
+ settingDeleteGuard: { commit: '', pr: '' },
338
+ recommended: false,
339
+ defaultValue: false,
340
+ },
312
341
  // ── Valued flags (number/enum/string) ────────────────────────────────────
313
342
  {
314
343
  // Domain: unset by default; set only when users want a non-default spawn depth.
@@ -837,6 +866,78 @@ export function migrateLegacyFlagsToRecord(enabledIds, knownIds, legacyViewMode)
837
866
  return result;
838
867
  }
839
868
  // ─── Apply / Strip ────────────────────────────────────────────────────────────
869
+ /**
870
+ * Returns true when `value` equals the managed shape declared by `flag.settingDeleteGuard`.
871
+ * Returns false when: the flag has no guard, is not a setting-target boolean, or the value
872
+ * does not deep-equal the guard.
873
+ *
874
+ * Single equality oracle for managed-shape comparisons — used by `canDeleteSettingKey`,
875
+ * `settingHoldsManagedShape`, and the Step 2b adoption fold in `convergeFlagsIntoSettings`.
876
+ * All three delegate here so there is exactly one `isDeepStrictEqual` call for guard matching.
877
+ */
878
+ export function settingValueHoldsManagedShape(flag, value) {
879
+ if (flag.kind !== 'boolean' || flag.target.type !== 'setting')
880
+ return false;
881
+ const guard = flag.settingDeleteGuard;
882
+ if (guard === undefined)
883
+ return false;
884
+ return isDeepStrictEqual(value, guard);
885
+ }
886
+ /**
887
+ * D-ATTR-GUARD single-source predicate (consistency-01 / ADR-024).
888
+ *
889
+ * Returns true when the settings.json string contains a value at the flag's
890
+ * target key that equals the flag's managed shape (`settingDeleteGuard`).
891
+ *
892
+ * This is the ONE place that decides "on-disk value equals the managed shape".
893
+ * All consumers — `resolveExistingAttributionSuppression` (seeding priority),
894
+ * and the guarded-boolean adoption fold in `convergeFlagsIntoSettings` — delegate
895
+ * here rather than hand-rolling their own comparison.
896
+ *
897
+ * Returns false when:
898
+ * - settingsJson is malformed
899
+ * - root is not a plain object
900
+ * - flagId is not in the registry
901
+ * - the flag has no settingDeleteGuard
902
+ * - the flag is not a setting-target boolean
903
+ * - the on-disk value does not deep-equal the guard
904
+ */
905
+ export function settingHoldsManagedShape(settingsJson, flagId) {
906
+ try {
907
+ const parsed = JSON.parse(settingsJson);
908
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
909
+ return false;
910
+ const flag = FLAG_REGISTRY_MAP.get(flagId);
911
+ if (!flag)
912
+ return false;
913
+ const value = parsed[flag.target.key];
914
+ return settingValueHoldsManagedShape(flag, value);
915
+ }
916
+ catch {
917
+ return false;
918
+ }
919
+ }
920
+ /**
921
+ * D-ATTR-GUARD: returns true when it is safe to delete a flag's target key from
922
+ * settings. For flags with a settingDeleteGuard the key is only deleted when the
923
+ * current on-disk value deep-equals the managed shape, preventing erasure of
924
+ * user-customized values (e.g. an organisation attribution block) on flag
925
+ * disable or uninstall. Flags without a guard are always safe to delete.
926
+ *
927
+ * Delegates to `settingValueHoldsManagedShape` — the single equality oracle for
928
+ * managed-shape comparisons — so there is exactly one `isDeepStrictEqual` call
929
+ * across the entire apply/strip pipeline (ADR-024 mechanism 3).
930
+ *
931
+ * Single-source invariant: both the disable path (applyFlags neutral branch) and
932
+ * the uninstall path (stripFlags) collapse to this predicate.
933
+ */
934
+ function canDeleteSettingKey(flag, settings) {
935
+ if (flag.kind !== 'boolean')
936
+ return true;
937
+ if (flag.settingDeleteGuard === undefined)
938
+ return true;
939
+ return settingValueHoldsManagedShape(flag, settings[flag.target.key]);
940
+ }
840
941
  /**
841
942
  * Return `v` as a `Record<string, unknown>` only when it is a plain object.
842
943
  * Returns undefined for arrays, null, or non-objects.
@@ -853,8 +954,17 @@ function asPlainObject(v) {
853
954
  /** Compute the value to write to settings.json for an active flag. */
854
955
  function buildPayload(flag, value) {
855
956
  switch (flag.kind) {
856
- case 'boolean':
857
- return flag.onPayload;
957
+ case 'boolean': {
958
+ // D-PAYLOAD-CLONE: return a structural clone for object payloads so the
959
+ // FLAG_REGISTRY entry is never aliased into the caller's settings tree.
960
+ // Primitives (string, boolean) are values and require no clone.
961
+ // isEnvBooleanFlag narrows onPayload to string for env flags — no clone needed,
962
+ // and the string type avoids an `unknown` hop into the env string-map.
963
+ if (isEnvBooleanFlag(flag))
964
+ return flag.onPayload; // string — no clone needed
965
+ const p = flag.onPayload;
966
+ return typeof p === 'object' && p !== null ? structuredClone(p) : p;
967
+ }
858
968
  case 'enum':
859
969
  return value;
860
970
  case 'number':
@@ -904,7 +1014,8 @@ export function applyFlags(settingsJson, flags) {
904
1014
  delete env[flag.target.key];
905
1015
  }
906
1016
  else {
907
- delete settings[flag.target.key];
1017
+ if (canDeleteSettingKey(flag, settings))
1018
+ delete settings[flag.target.key];
908
1019
  }
909
1020
  }
910
1021
  else {
@@ -949,7 +1060,8 @@ export function stripFlags(settingsJson) {
949
1060
  delete env[flag.target.key];
950
1061
  }
951
1062
  else {
952
- delete settings[flag.target.key];
1063
+ if (canDeleteSettingKey(flag, settings))
1064
+ delete settings[flag.target.key];
953
1065
  }
954
1066
  }
955
1067
  if (env && Object.keys(env).length === 0) {
@@ -1116,6 +1228,35 @@ export function convergeFlagsIntoSettings(settingsJson, record, opts) {
1116
1228
  folded[flag.id] = coerced;
1117
1229
  }
1118
1230
  }
1231
+ // ── Step 2b: adopt guarded boolean settings before the strip ─────────────
1232
+ // D-ATTR-ADOPT (PF-050 / ADR-024): a delete guard is evidence about the VALUE,
1233
+ // never about the record — a pre-existing on-disk key whose value matches the
1234
+ // managed shape means devflow wrote it, so adopt it into the record now, before
1235
+ // stripFlags can unconditionally remove it on the next line.
1236
+ //
1237
+ // Mirror of the view-mode fold (Step 1): adopt only when:
1238
+ // (a) the flag has a settingDeleteGuard (guarded boolean only)
1239
+ // (b) the record does NOT already claim the key — a claimed false or null still
1240
+ // deletes the managed block (the user intentionally disabled it)
1241
+ // (c) the pre-strip on-disk value holds the managed shape
1242
+ //
1243
+ // This fold runs before stripFlags so applyFlags rewrites the block from the
1244
+ // now-claimed record and the block survives on BOTH the init and flags paths.
1245
+ for (const flag of FLAG_REGISTRY) {
1246
+ if (flag.kind !== 'boolean')
1247
+ continue;
1248
+ if (flag.target.type !== 'setting')
1249
+ continue;
1250
+ // Skip when the record already claims this flag (claimed false/null deletes the block)
1251
+ const claimed = claimedIn !== null &&
1252
+ Object.prototype.hasOwnProperty.call(claimedIn, flag.id);
1253
+ if (claimed)
1254
+ continue;
1255
+ // Adopt only when the on-disk value matches the managed shape exactly
1256
+ if (settingValueHoldsManagedShape(flag, parsed[flag.target.key])) {
1257
+ folded[flag.id] = true;
1258
+ }
1259
+ }
1119
1260
  // ── Step 3: strip all managed keys, then apply the folded record ──────────
1120
1261
  const stripped = stripFlags(settingsJson);
1121
1262
  const settings = applyFlags(stripped, folded);
@@ -29,8 +29,10 @@ export const PROXY_LOG_TAIL_BYTES = 1_048_576;
29
29
  * (e.g. SUBSWITCH_CONFIG for the relay spawn and doctor spawn).
30
30
  *
31
31
  * applies ADR-003: the prior denylist rationale is gone — the routing runtime
32
- * reads exactly three env vars (ANTHROPIC_API_KEY, FORCE_COLOR, SUBSWITCH_CONFIG).
33
- * Verified by whole-dist grep of the 0.2.0 package. An allowlist is the correct
32
+ * reads exactly four env vars (ANTHROPIC_API_KEY, FORCE_COLOR, NO_COLOR,
33
+ * SUBSWITCH_CONFIG — NO_COLOR read in dist/tty.js with presence semantics; CI is
34
+ * read only by the `init` subcommand which devflow never invokes).
35
+ * Verified by whole-dist grep of the 0.4.0 package. An allowlist is the correct
34
36
  * shape: 61 inherited vars → 6.
35
37
  *
36
38
  * HOME is retained: the runtime's loadConfig resolves ~ paths via homedir().
@@ -88,47 +88,72 @@ export async function writeProxyState(devflowDir, state) {
88
88
  // Pure helpers
89
89
  // ---------------------------------------------------------------------------
90
90
  /**
91
- * Accepted top-level keys for the subswitch 0.2.0 FileConfigSchema (z.strictObject).
91
+ * Accepted top-level keys for the subswitch 0.4.0 FileConfigSchema (z.strictObject).
92
92
  * Unknown top-level keys cause a hard relay startup error — never emit them.
93
93
  *
94
- * @D-EFR-4 Subswitch 0.2.0 routing config contract:
94
+ * @D-EFR-4 Subswitch 0.4.0 routing config contract:
95
95
  * FileConfigSchema is a z.strictObject with exactly 5 accepted top-level keys:
96
96
  * port, logLevel, anthropic, providers, limits. Unknown keys cause a hard startup
97
97
  * error — the relay refuses to start. anthropic and limits are themselves
98
98
  * strictObject + prefault({}), so they may be partially specified.
99
- *
100
- * connectTimeoutMs default: subswitch 0.2.0 applies anthropic.connectTimeoutMs
101
- * as upstream.setTimeout() at anthropic-passthrough.js:86 — a socket *inactivity*
102
- * timeout, not a connect timeout. The 10s default kills any Anthropic request
103
- * whose upstream takes >10s to emit the first response byte — routine for long
104
- * opus requests (confirmed: 99 spurious 504s clustered at 10004-10098ms while
105
- * successful requests ran 25s-99s). Default overridden to 120 000ms; a
106
- * user-specified value always wins.
107
- *
108
- * limits.connectTimeoutMs is a registered LEGACY KEY that causes a hard startup
109
- * error. Strip it from the limits block when preserving existing config.
110
99
  */
111
100
  const ROUTING_CONFIG_ALLOWED_TOP_KEYS = new Set([
112
101
  'port', 'logLevel', 'anthropic', 'providers', 'limits',
113
102
  ]);
114
- /** Default anthropic.connectTimeoutMs injected when not specified by the user (ms). */
115
- export const DEFAULT_ANTHROPIC_CONNECT_TIMEOUT_MS = 120_000;
103
+ /**
104
+ * Sub-keys of a preserved `anthropic` / `limits` block that the pinned relay rejects
105
+ * outright — each is a registered LEGACY KEY, and a legacy key is a hard startup
106
+ * error naming its replacement, not a warning. They are stripped when carrying a
107
+ * user's existing config forward so an upgrade cannot leave the relay unable to boot.
108
+ *
109
+ * Every entry was valid under a version devflow previously pinned, which is what makes
110
+ * it reachable in a real user's file:
111
+ * anthropic.streamIdleTimeoutMs — valid in 0.2.0, removed in 0.3.0 (relay no longer
112
+ * bounds the stream-idle phase on a connected client).
113
+ * limits.connectTimeoutMs — moved to anthropic.connectTimeoutMs in 0.3.0;
114
+ * stripping it loses nothing (relay default governs).
115
+ * limits.maxConcurrentRequests — valid in 0.2.0, removed in 0.3.0 (admission gate
116
+ * removed).
117
+ * limits.maxBodyBytes — valid through 0.3.0, renamed to
118
+ * limits.maxBufferedBodyBytes in 0.4.0. The old spelling is a registered legacy
119
+ * key, so leaving it in place would stop the relay from booting.
120
+ * limits.maxUpstreamSockets — valid in 0.3.0, moved to anthropic.maxUpstreamSockets
121
+ * in 0.4.0's providers.* restructure.
122
+ * limits.streamIdleTimeoutMs — valid in 0.3.0, moved to
123
+ * providers.codex.streamIdleTimeoutMs in 0.4.0.
124
+ * limits.requestTimeoutMs — valid in 0.3.0, moved to
125
+ * providers.codex.requestTimeoutMs in 0.4.0.
126
+ * limits.maxSseEventBytes — valid in 0.3.0, moved to
127
+ * providers.codex.maxSseEventBytes in 0.4.0.
128
+ *
129
+ * Keys retired before the 0.2.0 baseline are deliberately absent: only keys reachable
130
+ * in a config written by a previously pinned devflow version belong on this list.
131
+ */
132
+ const ROUTING_CONFIG_REJECTED_SUBKEYS = {
133
+ anthropic: ['streamIdleTimeoutMs'],
134
+ limits: [
135
+ 'connectTimeoutMs',
136
+ 'maxConcurrentRequests',
137
+ 'maxBodyBytes',
138
+ 'maxUpstreamSockets',
139
+ 'streamIdleTimeoutMs',
140
+ 'requestTimeoutMs',
141
+ 'maxSseEventBytes',
142
+ ],
143
+ };
116
144
  /**
117
145
  * Build the routing config JSON for ~/.devflow/proxy-routing.json.
118
146
  *
119
147
  * Authoritatively sets `port`. Preserves any existing valid `anthropic`,
120
- * `limits`, `logLevel`, and `providers` blocks from `existingContent`,
121
- * filtering out unknown top-level keys and the legacy `limits.connectTimeoutMs`
122
- * field (which causes a hard relay startup error in 0.2.0).
123
- *
124
- * Injects a default `anthropic.connectTimeoutMs` of 120 000ms when the user
125
- * has not set one — see @D-EFR-4 for the rationale.
148
+ * `limits`, `logLevel`, and `providers` blocks from `existingContent`, filtering
149
+ * out unknown top-level keys and every sub-key in ROUTING_CONFIG_REJECTED_SUBKEYS
150
+ * (each one a hard relay startup error). No `anthropic` block is injected when
151
+ * the user has not set one — the relay's own default governs (D-EFR-4).
126
152
  *
127
- * If `existingContent` is missing or malformed, falls back to a clean config
128
- * with only `port` and `anthropic.connectTimeoutMs`.
153
+ * If `existingContent` is missing or malformed, falls back to a port-only config.
129
154
  *
130
155
  * applies ADR-013: pure core-layer function — no I/O.
131
- * @D-EFR-4: see note above for the strict top-key constraint and timeout rationale.
156
+ * @D-EFR-4: see note above for the strict top-key constraint.
132
157
  */
133
158
  export function buildRoutingConfigJson(port, existingContent) {
134
159
  // Parse existing config tolerantly; a malformed file falls back to clean defaults.
@@ -154,30 +179,36 @@ export function buildRoutingConfigJson(port, existingContent) {
154
179
  if (preserved.logLevel !== undefined) {
155
180
  config.logLevel = preserved.logLevel;
156
181
  }
157
- // Anthropic block: preserve user settings; inject connectTimeoutMs default when absent.
158
- // @D-EFR-4: the 10s upstream inactivity timeout kills long opus requests.
159
- const existingAnthropic = typeof preserved.anthropic === 'object' &&
182
+ // Anthropic block: preserve user settings; strip legacy rejected sub-keys.
183
+ // No default is injected — the relay's own default governs (D-EFR-4).
184
+ if (typeof preserved.anthropic === 'object' &&
160
185
  preserved.anthropic !== null &&
161
- !Array.isArray(preserved.anthropic)
162
- ? { ...preserved.anthropic }
163
- : {};
164
- if (!Object.prototype.hasOwnProperty.call(existingAnthropic, 'connectTimeoutMs')) {
165
- existingAnthropic.connectTimeoutMs = DEFAULT_ANTHROPIC_CONNECT_TIMEOUT_MS;
186
+ !Array.isArray(preserved.anthropic)) {
187
+ const existingAnthropic = { ...preserved.anthropic };
188
+ for (const key of ROUTING_CONFIG_REJECTED_SUBKEYS.anthropic) {
189
+ delete existingAnthropic[key];
190
+ }
191
+ if (Object.keys(existingAnthropic).length > 0) {
192
+ config.anthropic = existingAnthropic;
193
+ }
166
194
  }
167
- config.anthropic = existingAnthropic;
168
195
  // Preserve providers block if present.
169
196
  if (preserved.providers !== undefined) {
170
197
  config.providers = preserved.providers;
171
198
  }
172
- // Preserve limits block if present; strip legacy limits.connectTimeoutMs that
173
- // causes a hard startup error in 0.2.0 (use anthropic.connectTimeoutMs instead).
199
+ // Preserve limits block if present, minus the sub-keys the relay hard-errors on
200
+ // (see ROUTING_CONFIG_REJECTED_SUBKEYS).
174
201
  if (preserved.limits !== undefined) {
175
202
  if (typeof preserved.limits === 'object' &&
176
203
  preserved.limits !== null &&
177
204
  !Array.isArray(preserved.limits)) {
178
205
  const limitsObj = { ...preserved.limits };
179
- delete limitsObj['connectTimeoutMs'];
180
- config.limits = limitsObj;
206
+ for (const key of ROUTING_CONFIG_REJECTED_SUBKEYS.limits) {
207
+ delete limitsObj[key];
208
+ }
209
+ if (Object.keys(limitsObj).length > 0) {
210
+ config.limits = limitsObj;
211
+ }
181
212
  }
182
213
  else {
183
214
  config.limits = preserved.limits;
@@ -210,6 +241,29 @@ export function proxyBaseUrl(port) {
210
241
  // ---------------------------------------------------------------------------
211
242
  // isProxyEnabled — the primary contract other modules consume
212
243
  // ---------------------------------------------------------------------------
244
+ /**
245
+ * Returns true when ~/.devflow/proxy.json exists on disk — i.e. Devflow has
246
+ * previously managed the proxy on this machine.
247
+ *
248
+ * This is the evidence gate used by init and uninstall before stripping
249
+ * ANTHROPIC_BASE_URL / CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT:
250
+ * we only strip those vars when we can prove Devflow wrote them.
251
+ *
252
+ * D-STRIP-1: gate proxy env stripping on devflow-managed evidence.
253
+ * readProxyState() returns Ok(defaultState) on ENOENT — it cannot distinguish
254
+ * "file absent" from "file present with DEFAULT_PROXY_PORT". Callers that need
255
+ * to differentiate must use proxyJsonExists() rather than checking the result
256
+ * of readProxyState().
257
+ */
258
+ export async function proxyJsonExists(devflowDir) {
259
+ try {
260
+ await fs.access(join(devflowDir, 'proxy.json'));
261
+ return true;
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
213
267
  /**
214
268
  * Check whether the Devflow proxy is currently enabled.
215
269
  * Returns false when the proxy state file is missing, unreadable, or malformed.
@@ -712,10 +712,95 @@ export async function stripUserSecurityDenyList(settingsPath) {
712
712
  await writeFileAtomicExclusive(settingsPath, stripped);
713
713
  return { removed };
714
714
  }
715
+ /** True for a non-null, non-array object literal. */
716
+ function isPlainObject(value) {
717
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
718
+ }
719
+ /**
720
+ * Every `command` string carried by a hook matcher, tolerating foreign shapes.
721
+ * Returns an empty array for anything that is not `{ hooks: [{ command: string }] }`.
722
+ */
723
+ function hookCommandsOf(matcher) {
724
+ if (!isPlainObject(matcher) || !Array.isArray(matcher.hooks))
725
+ return [];
726
+ return matcher.hooks
727
+ .filter(isPlainObject)
728
+ .map((h) => h.command)
729
+ .filter((c) => typeof c === 'string');
730
+ }
731
+ /**
732
+ * Merge Devflow's template hook entries and top-level fields into an existing
733
+ * parsed settings object. Idempotent by exact command string — a hook that is
734
+ * already present is skipped. Sets `statusLine` only when the user has no existing
735
+ * value. Attribution is NOT injected here — managed by the flags pipeline (D27).
736
+ * Mutates `existing` in place.
737
+ *
738
+ * D-SETTINGS-1: merge strategy — never replace; only add devflow entries that are absent.
739
+ * Returns { changed: true } when any field was added.
740
+ *
741
+ * `existing` comes from a hand-editable file, so every branch is shape-guarded:
742
+ * a `hooks` value (or per-event value) that is not the expected object/array shape
743
+ * is left untouched rather than overwritten or thrown on (applies PF-023 — validate
744
+ * at the sink that mutates).
745
+ *
746
+ * Exported for testing.
747
+ */
748
+ export function mergeDevflowSettingsTemplate(existing, template) {
749
+ let changed = false;
750
+ // Merge hook entries — idempotent by exact command string
751
+ const tmplHooks = isPlainObject(template.hooks) ? template.hooks : {};
752
+ const existingHooksRaw = existing.hooks;
753
+ if (existingHooksRaw === undefined || isPlainObject(existingHooksRaw)) {
754
+ const existingHooks = existingHooksRaw ?? {};
755
+ let hooksChanged = false;
756
+ for (const [event, matchers] of Object.entries(tmplHooks)) {
757
+ if (!Array.isArray(matchers))
758
+ continue;
759
+ for (const matcher of matchers) {
760
+ const cmd = hookCommandsOf(matcher)[0];
761
+ if (!cmd)
762
+ continue;
763
+ const current = existingHooks[event];
764
+ if (current !== undefined && !Array.isArray(current))
765
+ break; // foreign shape — leave the event alone
766
+ const eventArr = current ?? [];
767
+ if (eventArr.some((m) => hookCommandsOf(m).includes(cmd)))
768
+ continue;
769
+ eventArr.push(matcher);
770
+ existingHooks[event] = eventArr;
771
+ hooksChanged = true;
772
+ }
773
+ }
774
+ // Attach only when something was actually added — never introduce an empty
775
+ // `hooks` key into a settings.json that had none.
776
+ if (hooksChanged) {
777
+ existing.hooks = existingHooks;
778
+ changed = true;
779
+ }
780
+ }
781
+ // Set statusLine only if the user has none
782
+ if (existing.statusLine === undefined && template.statusLine !== undefined) {
783
+ existing.statusLine = template.statusLine;
784
+ changed = true;
785
+ }
786
+ // attribution: owned exclusively by the flags pipeline (applyFlags/stripFlags, D27) — a second writer here would race it.
787
+ return { changed };
788
+ }
715
789
  /**
716
790
  * Install or update settings.json with Devflow configuration.
717
- * Prompts interactively in TTY mode when settings already exist.
718
- * In non-TTY mode, skips override (safe default).
791
+ *
792
+ * Strategy (D-SETTINGS-1: merge, never overwrite wholesale):
793
+ * - Fresh file: write the template directly.
794
+ * - Existing file: MERGE devflow hook entries and fields into the parsed object.
795
+ * Idempotent by exact command string — existing hooks are never duplicated or removed.
796
+ * Preserves every user key (env, permissions, apiKeyHelper, model, etc.) untouched.
797
+ * - Parse failure: warn and skip; file left byte-identical (never clobber a broken file).
798
+ *
799
+ * The merge is additive only, so it runs without a confirmation prompt: init is called
800
+ * from inside an active spinner (a prompt would render on top of it), and declining
801
+ * could not protect the file anyway — init's own settings pass rewrites the hook set
802
+ * unconditionally right afterwards. A prompt whose answer changes nothing is worse
803
+ * than no prompt.
719
804
  *
720
805
  * The deny list is handled by init's dedicated security step
721
806
  * (applyUserSecurityDenyList / installManagedSettings) after installSettings completes.
@@ -741,38 +826,30 @@ export async function installSettings(claudeDir, rootDir, devflowDir, verbose) {
741
826
  }
742
827
  return;
743
828
  }
744
- // Settings exist — check if they already have hooks
745
- let hasHooks = false;
829
+ // Settings exist — parse and merge (never overwrite wholesale)
830
+ let existingParsed;
746
831
  try {
747
- const existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
748
- hasHooks = !!existing.hooks;
832
+ existingParsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
749
833
  }
750
- catch { /* parse error = treat as no hooks */ }
751
- if (hasHooks) {
752
- // Settings already configured with hooks — nothing to do
834
+ catch {
835
+ // Parse failure — warn and skip; file left byte-identical (D-SETTINGS-1)
836
+ if (verbose) {
837
+ p.log.warn('settings.json could not be parsed — Devflow hooks not added. ' +
838
+ 'Fix the JSON manually and re-run devflow init.');
839
+ }
753
840
  return;
754
841
  }
755
- // Settings exist without hooks — prompt in TTY, warn in non-TTY
756
- if (process.stdin.isTTY) {
757
- const confirmed = await p.confirm({
758
- message: 'settings.json exists without hooks (Working Memory needs hooks). Override?',
759
- initialValue: true,
760
- });
761
- if (p.isCancel(confirmed)) {
762
- p.cancel('Installation cancelled.');
763
- process.exit(0);
764
- }
765
- if (confirmed) {
766
- await fs.writeFile(settingsPath, settingsContent, 'utf-8');
767
- p.log.success('Settings overridden');
768
- }
769
- else {
770
- p.log.info('Keeping existing settings');
771
- }
842
+ const templateParsed = JSON.parse(settingsContent);
843
+ // Merge template into existing (mutates existingParsed in place).
844
+ // If nothing needs to be added the write is skipped entirely.
845
+ const { changed } = mergeDevflowSettingsTemplate(existingParsed, templateParsed);
846
+ if (!changed) {
847
+ // Already fully configured — nothing to do
848
+ return;
772
849
  }
773
- else {
774
- p.log.warn('Settings exist without hooks. Working Memory requires hooks.');
775
- p.log.info('Re-run interactively to configure, or manually add hooks to settings.json');
850
+ await writeFileAtomicExclusive(settingsPath, JSON.stringify(existingParsed, null, 2) + '\n');
851
+ if (verbose) {
852
+ p.log.success('Settings updated with Devflow hooks and HUD');
776
853
  }
777
854
  }
778
855
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devflow-kit",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "A meta-harness for Claude Code — turns a single coding agent into an engineering team: orchestration, parallel review, persistent memory, self-learning, and graph workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "@clack/prompts": "^0.9.1",
64
64
  "commander": "^12.0.0",
65
65
  "picocolors": "^1.1.1",
66
- "subswitch": "0.2.0"
66
+ "subswitch": "0.4.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@mdscript/mds": "0.2.0",