devflow-kit 2.2.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
@@ -50,7 +50,7 @@ In multi-worktree mode, spawn all pre-flight agents **in a single message** (par
50
50
 
51
51
  **If BLOCKED:** In single-worktree mode, stop and report the blocker to user. If no reviews found, suggest `/code-review` or `/bug-analysis` first. In multi-worktree mode, report the failure but continue with other worktrees.
52
52
 
53
- **Extract from response:** `branch`, `branch_slug`, `pr_number`, `review_count`, `diff_files` per worktree.
53
+ **Extract from response:** `branch`, `base_branch`, `branch_slug`, `pr_number`, `review_count`, `diff_files` per worktree.
54
54
 
55
55
  **Fetch PR body** (after extracting `pr_number`):
56
56
  ```bash
@@ -186,7 +186,7 @@ Issues are extracted from `{TARGET_DIR}` only — never cross-reference reviews
186
186
  ### Phase 1b: Fetch External Review Threads (Compliance-gated)
187
187
 
188
188
  **Produces:** THREAD_MAP
189
- **Requires:** PR_INFO, COMPLIANCE_SKILL_INSTALLED
189
+ **Requires:** BRANCH_INFO, COMPLIANCE_SKILL_INSTALLED
190
190
 
191
191
  Skip this phase if `COMPLIANCE_SKILL_INSTALLED` is false.
192
192
 
@@ -205,7 +205,7 @@ Parse `THREAD_MAP` from Git agent output. If Git agent returns `TRACEABILITY: DE
205
205
  ### Phase 2: Global Triage
206
206
 
207
207
  **Produces:** TRIAGE_RESULTS
208
- **Requires:** ISSUES, DIFF_FILES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, PR_DESCRIPTION, BRANCH_INFO
208
+ **Requires:** ISSUES, DIFF_FILES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, PR_DESCRIPTION
209
209
 
210
210
  Spawn a single global Triage agent for ALL issues:
211
211
 
@@ -217,7 +217,7 @@ WORKTREE_PATH: {worktree_path} (omit if cwd)
217
217
  DECISIONS_CONTEXT: {decisions_context}
218
218
  FEATURE_KNOWLEDGE: {feature_knowledge}
219
219
  PR_DESCRIPTION: <pr-description>{pr_description}</pr-description>
220
- Triage every issue using the blast-radius disposition matrix. Assign exactly one verdict per issue.
220
+ Triage every issue: collapse duplicates first, then apply the blast-radius disposition matrix to each group's primary. Assign exactly one verdict per issue.
221
221
  Follow devflow:apply-decisions to Read full ADR/PF bodies on demand.
222
222
  Follow devflow:apply-feature-knowledge for FEATURE_KNOWLEDGE."
223
223
  ```
@@ -229,11 +229,12 @@ Wait for Triage agent to complete before proceeding. Parse verdict ledger from T
229
229
  - **BY_DESIGN**: Intentional code (with ADR or code doc citation)
230
230
  - **FIX_SEPARATE**: Valid but out of blast-radius scope (must become manage-debt ticket)
231
231
  - **TECH_DEBT**: Architectural overhaul only — LAST RESORT
232
+ - **DUPLICATE**: Collapsed duplicate issue — carries `duplicate_of: <primary-id>` referencing the non-DUPLICATE primary; inherits the primary's outcome
232
233
 
233
234
  Collect all decisions citations (ADR-NNN / PF-NNN) from Triage agent Reasoning columns.
234
235
 
235
236
  **Triage agent completeness assertion (avoids PF-002):** Verify the parsed ledger against ISSUES before proceeding:
236
- 1. Every issue `id` from ISSUES must appear in exactly one verdict bucket — none may vanish, none may appear in multiple buckets.
237
+ 1. Every issue `id` from ISSUES must appear in exactly one verdict bucket — none may vanish, none may appear in multiple buckets. DUPLICATE is a valid bucket; a valid DUPLICATE entry must name its `duplicate_of` primary (the `Duplicate Of` column of the ledger's DUPLICATE table) and that primary must be a non-DUPLICATE issue id. A missing `duplicate_of` or one that chains to another DUPLICATE is a **Triage agent failure** (retry-then-abort as below).
237
238
  2. If the Triage agent output is empty, contains a skill re-entrancy guard string (e.g., contains `already running`), or is missing any issue IDs from ISSUES: treat as a **Triage agent failure**:
238
239
  - Retry the Triage agent once with the same inputs.
239
240
  - If the retry also fails the completeness check: abort with a clear error message listing the missing issue IDs and failure reason — never proceed with dropped issues.
@@ -247,7 +248,7 @@ Collect all decisions citations (ADR-NNN / PF-NNN) from Triage agent Reasoning c
247
248
 
248
249
  If FIX_NOW list is empty: skip to Phase 5 — write full summary (Phase 5), run manage-debt (Phase 9) if FIX_SEPARATE/TECH_DEBT exist, run thread resolution + resolution comment (Phase 9b), run merge readiness (Phase 9c), display results (Phase 10).
249
250
 
250
- Otherwise, batch FIX_NOW issues for Code agent execution:
251
+ Otherwise, batch FIX_NOW issues for Code agent execution. **DUPLICATE issues are never dispatched** — they inherit the primary's outcome:
251
252
  - **Same-file issues** → one batch (one Code agent per file, sequential for same-file pairs)
252
253
  - **Distinct-file issues** → parallel Code agents
253
254
  - **Max 5 issues per batch** — chunk large sets
@@ -255,7 +256,7 @@ Otherwise, batch FIX_NOW issues for Code agent execution:
255
256
  ### Phase 4: Fix (Code agent × N)
256
257
 
257
258
  **Produces:** CODE_AGENT_RESULTS
258
- **Requires:** BATCHES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, BRANCH_INFO
259
+ **Requires:** BATCHES, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE
259
260
 
260
261
  For each batch, spawn Code agent with `OPERATION: issue-fix` and `PUSH: false`:
261
262
 
@@ -284,12 +285,14 @@ Collect from each Code agent:
284
285
  ### Phase 5: Write resolution-summary.md
285
286
 
286
287
  **Produces:** RESOLUTION_FILE (early write for compaction safety)
287
- **Requires:** TRIAGE_RESULTS, CODE_AGENT_RESULTS
288
+ **Requires:** TRIAGE_RESULTS, CODE_AGENT_RESULTS, TARGET_DIR, BRANCH_INFO
288
289
 
289
290
  **Immediately write `resolution-summary.md`** to `{TARGET_DIR}` using the Write tool. Do this now — not in Phase 9 — while results are fresh in context. This ensures the record is persisted even if later phases (Simplify, Verification Gate, CI gate, Tech Debt) trigger context compaction.
290
291
 
291
292
  Set `Tracked` for FIX_SEPARATE and TECH_DEBT items to `(pending)` — to be backfilled after Phase 9 manage-debt.
292
293
 
294
+ DUPLICATE issues are listed **only** in `## Duplicates` — never in `## Fixed Issues`, `## False Positives`, `## By Design`, `## Fix Separately`, `## Deferred to Tech Debt`, `## Escalations`, or `## Blocked`. A duplicate of a FALSE_POSITIVE primary therefore leaves only the primary in the `False Positive` row and the `## False Positives` section; the same holds for every other outcome the duplicate inherits.
295
+
293
296
  Use the template from the Output Artifact section below.
294
297
 
295
298
  ### Phase 6: Simplify
@@ -382,7 +385,7 @@ Otherwise, for each worktree with fixes:
382
385
 
383
386
  **IMPORTANT**: Run sequentially across all worktrees (not in parallel) to avoid GitHub API conflicts.
384
387
 
385
- If any issues are FIX_SEPARATE or TECH_DEBT, spawn Git agent:
388
+ If any issues are FIX_SEPARATE or TECH_DEBT, spawn Git agent. **DUPLICATE issues never create their own debt tickets** — a duplicate of a deferred primary is covered by the primary's ticket:
386
389
 
387
390
  ```
388
391
  Agent(subagent_type="Git"):
@@ -407,6 +410,7 @@ Skip this step if `COMPLIANCE_SKILL_INSTALLED` is false or THREAD_MAP is empty.
407
410
 
408
411
  Prepare THREAD_MAP with verdicts from triage/code agent results:
409
412
  - For each `ext-{N}`: match to an issue verdict (FIXED, FALSE_POSITIVE, BY_DESIGN, ESCALATED) by `file:line` correlation
413
+ - If the matched issue has verdict DUPLICATE, use the **primary's** verdict and verification status for the reply — do not expose DUPLICATE to the thread author (avoids PF-024; caller-side mapping — git.md contracts unchanged)
410
414
  - Include `commit_sha` from Code agent results for FIXED verdicts
411
415
  - Unmatched threads: ESCALATED (human review)
412
416
 
@@ -447,7 +451,7 @@ Update `## Third-Party Threads` section in resolution-summary.md with thread res
447
451
  ### Phase 9c: Merge Readiness (Compliance-gated, Report-only)
448
452
 
449
453
  **Produces:** MERGE_READINESS_REPORT
450
- **Requires:** PR_INFO, COMPLIANCE_SKILL_INSTALLED
454
+ **Requires:** BRANCH_INFO, COMPLIANCE_SKILL_INSTALLED
451
455
 
452
456
  Skip this phase if `COMPLIANCE_SKILL_INSTALLED` is false.
453
457
 
@@ -466,7 +470,7 @@ If Git agent returns `TRACEABILITY: DEGRADED`: warn, proceed to Phase 10.
466
470
 
467
471
  ### Phase 10: Report
468
472
 
469
- **Requires:** TARGET_DIR
473
+ **Requires:** BRANCH_INFO, TARGET_DIR
470
474
 
471
475
  The resolution summary was already written to `{TARGET_DIR}/resolution-summary.md` in Phase 5 (updated by Phase 7 and Phase 9). Display results to the user:
472
476
 
@@ -486,6 +490,7 @@ The resolution summary was already written to `{TARGET_DIR}/resolution-summary.m
486
490
  | Deferred | {n} |
487
491
  | Blocked | {n} |
488
492
  | Escalated | {n} |
493
+ | Duplicates Collapsed | {n} |
489
494
 
490
495
  ### Verification
491
496
  Final gate: {PASS | FAILED after N attempts}
@@ -578,9 +583,9 @@ After writing, commit the two files to the current worktree branch yourself by r
578
583
  │ └─ Git agent (fetch-review-threads) → THREAD_MAP
579
584
  │
580
585
  ├─ Phase 2: Global Triage [Triage agent, opus, single agent]
581
- │ └─ ALL issues → verdict ledger by disposition
586
+ │ └─ ALL issues → verdict ledger by disposition (incl. DUPLICATE with duplicate_of)
582
587
  │
583
- ├─ Phase 3: Batch FIX_NOW issues (skip if empty)
588
+ ├─ Phase 3: Batch FIX_NOW issues (skip if empty; DUPLICATE issues never dispatched)
584
589
  │ └─ same-file sequential, distinct-file parallel, max 5/batch
585
590
  │
586
591
  ├─ Phase 4: Fix [Code agent × N, OPERATION: issue-fix, PUSH: false]
@@ -623,6 +628,8 @@ After writing, commit the two files to the current worktree branch yourself by r
623
628
  | Worktree pre-flight fails | Report failure, continue with other worktrees |
624
629
  | Empty FIX_NOW list | Skip Phases 3-4/6-8; still write full summary + run manage-debt if FIX_SEPARATE/TECH_DEBT exist |
625
630
  | ESCALATED security issues | Surfaced in ## Escalations + display callout; never routed to manage-debt |
631
+ | DUPLICATE verdict without duplicate_of, or chained to another DUPLICATE | Treated as Triage failure — same retry-then-abort as a vanished id |
632
+ | DUPLICATE issues in THREAD_MAP | Map ext-{N} to primary's verdict/verification status for thread reply |
626
633
  | Verification Gate FAILED after 2 attempts | Recorded as FAILED in ## Verification + blocking callout; CI gate skipped; proceed to Phase 9 (manage-debt) then Phase 10 (display) |
627
634
  | gh/GitHub absent | manage-debt fails gracefully; Tracked stays "(pending)" + noted — recorded, not dropped |
628
635
  | COMPLIANCE_SKILL_INSTALLED false | Phases 1b, 9b-step-1, and 9c are skipped; post-resolution-summary (Phase 9b step 2) still runs if a PR is known |
@@ -651,7 +658,7 @@ Written in Phase 5 (Collect Results) to `{TARGET_DIR}/resolution-summary.md`:
651
658
  ```markdown
652
659
  # Resolution Summary
653
660
 
654
- **Branch**: {branch} -> {base}
661
+ **Branch**: {branch} -> {base_branch}
655
662
  **Date**: {timestamp}
656
663
  **Review**: {TARGET_DIR}
657
664
  **Command**: /resolve
@@ -673,8 +680,9 @@ Written in Phase 5 (Collect Results) to `{TARGET_DIR}/resolution-summary.md`:
673
680
  | Deferred | {n} |
674
681
  | Blocked | {n} |
675
682
  | Escalated | {n} |
683
+ | Duplicates Collapsed | {n} |
676
684
 
677
- _(Note: `Deferred` = `## Fix Separately` count + `## Deferred to Tech Debt` count combined — the two sections are distinct by scope, but the Statistics row aggregates both for the convergence parser.)_
685
+ _(Note: `Deferred` = `## Fix Separately` count + `## Deferred to Tech Debt` count combined — the two sections are distinct by scope, but the Statistics row aggregates both for the convergence parser. `Total Issues` counts every triaged issue including collapsed duplicates; every row **between** `Total Issues` and `Duplicates Collapsed` counts UNIQUE (non-DUPLICATE) issues only, so `Total Issues` equals the sum of the rows below it. Excluding duplicates from `Fixed`, `False Positive`, and `Deferred` de-skews the fp\_ratio convergence formula in code-review without any parser change.)_
678
686
 
679
687
  ## Verification
680
688
  | Command | Result |
@@ -720,6 +728,11 @@ Final gate: PASS | FAILED after {n} attempts
720
728
  |-------|-----------|---------|
721
729
  | {description} | {file}:{line} | {why} |
722
730
 
731
+ ## Duplicates
732
+ | Issue | Duplicate Of | File:Line |
733
+ |-------|-------------|-----------|
734
+ | {description} | {primary-id} | {file}:{line} |
735
+
723
736
  ## Third-Party Threads
724
737
  | Thread | File:Line | Verdict | Status |
725
738
  |--------|-----------|---------|--------|
@@ -728,4 +741,4 @@ Final gate: PASS | FAILED after {n} attempts
728
741
 
729
742
  _(Omit `## Third-Party Threads` if `COMPLIANCE_SKILL_INSTALLED` is false or no external threads were found.)_
730
743
 
731
- **Statistics mapping (parser contract)**: the `Deferred` row = FIX_SEPARATE + TECH_DEBT (both deferral dispositions combined); By Design and Escalated are counted separately and excluded from `Deferred`. The `/code-review` convergence parser reads the `Deferred`, `Fixed`, and `False Positive` Statistics rows plus the `## Fixed Issues` / `## False Positives` headings — keep those labels byte-stable.
744
+ **Statistics mapping (parser contract)**: the `Deferred` row = FIX_SEPARATE + TECH_DEBT (both deferral dispositions combined); By Design and Escalated are counted separately and excluded from `Deferred`. The `Duplicates Collapsed` row is additive — the `/code-review` convergence parser reads only `Deferred`, `Fixed`, and `False Positive` rows plus `## Fixed Issues` / `## False Positives` headings — keep those labels byte-stable. All rows that the parser reads count UNIQUE (non-DUPLICATE) issues only, so collapsed duplicates do not inflate fp\_ratio.
@@ -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.