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.
- package/CHANGELOG.md +21 -0
- package/dist/cli/commands/attribution-prompts.js +144 -0
- package/dist/cli/commands/compliance-prompts.js +7 -11
- package/dist/cli/commands/init-seed.js +45 -4
- package/dist/cli/commands/init.js +49 -6
- package/dist/cli/commands/prompt-io.js +38 -0
- package/dist/cli/commands/proxy.js +91 -31
- package/dist/cli/commands/uninstall.js +22 -12
- package/dist/core/flags.js +145 -4
- package/dist/core/proxy-log.js +4 -2
- package/dist/core/proxy-state.js +91 -37
- package/dist/targets/claude-code/post-install.js +106 -29
- package/package.json +2 -2
- package/src/assets/scripts/hooks/ensure-proxy +64 -5
- package/src/assets/skills/git/SKILL.md +1 -5
- package/src/assets/skills/git/references/patterns.md +0 -6
- package/src/targets/claude-code/templates/settings.json +0 -4
|
@@ -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 {
|
|
19
|
-
import { readProxyState,
|
|
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
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
|
999
|
+
catch { /* non-fatal */ }
|
|
990
1000
|
}
|
|
991
1001
|
}
|
|
992
1002
|
// Uninstall from each scope
|
package/dist/core/flags.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/core/proxy-log.js
CHANGED
|
@@ -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
|
|
33
|
-
*
|
|
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().
|
package/dist/core/proxy-state.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
/**
|
|
115
|
-
|
|
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
|
-
*
|
|
122
|
-
*
|
|
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
|
|
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
|
|
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;
|
|
158
|
-
//
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
173
|
-
//
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
718
|
-
*
|
|
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 —
|
|
745
|
-
let
|
|
829
|
+
// Settings exist — parse and merge (never overwrite wholesale)
|
|
830
|
+
let existingParsed;
|
|
746
831
|
try {
|
|
747
|
-
|
|
748
|
-
hasHooks = !!existing.hooks;
|
|
832
|
+
existingParsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
749
833
|
}
|
|
750
|
-
catch {
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
p.log.
|
|
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
|
+
"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.
|
|
66
|
+
"subswitch": "0.4.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@mdscript/mds": "0.2.0",
|