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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ All notable changes to Devflow will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.4.0] - 2026-09-01
9
+
10
+ ### Added
11
+ - **`suppress-attribution` flag** (optional boolean, default OFF): when enabled, writes `{"commit":"","pr":""}` to the `attribution` key in `settings.json`, suppressing Claude attribution trailers in git commits and PRs. Disabling (or uninstalling) removes the `attribution` key only when its current value exactly matches that managed shape — a custom attribution object is preserved, while enabling always replaces the existing value. Toggle via `devflow flags --enable/--disable suppress-attribution`.
12
+ - **Attribution wizard step in Advanced init** (D27): the Advanced-mode `devflow init` wizard asks one attribution question after the compliance step. Recommended init never asks; it silently applies the seeded value (off by default on a fresh install). The non-interactive path is `devflow flags --enable/--disable suppress-attribution` — there is no `--attribution` init flag.
13
+
14
+ ### Changed
15
+ - **`templates/settings.json` no longer ships an `attribution` block**: fresh installs now emit Claude attribution in git commits and PRs by default (where every prior install suppressed it). Existing installs are unaffected — see Upgrade note below.
16
+ - **`devflow:git` skill**: commit and PR templates no longer carry a hard-coded `Co-Authored-By: Claude` trailer or `Generated with Claude Code` footer. Attribution suppression is now an opt-in flag (`suppress-attribution`) rather than a hard-coded default.
17
+ - **Routing runtime pinned to `subswitch@0.4.0`** (from `0.2.0`). Over-window Anthropic-bound request bodies are now streamed upstream instead of being rejected by the relay, so long prompts no longer fail at the proxy; only translated (Codex) routes still return `413 request_too_large`. `buildRoutingConfigJson` no longer injects `anthropic.connectTimeoutMs` — the relay's own default (10 s, connect-only) governs; user-set values are preserved as before. The injection was a 0.2.0-era workaround artifact that outlived its purpose once the key's semantics were narrowed to DNS+TCP connect only.
18
+
19
+ ### Fixed
20
+ - **`proxy-routing.json` upgrade safety**: `buildRoutingConfigJson` now strips all seven `limits.*` sub-keys that `subswitch@0.4.0` promotes to hard startup errors: `limits.maxBodyBytes` (renamed `limits.maxBufferedBodyBytes`), `limits.maxUpstreamSockets` (moved to `anthropic.maxUpstreamSockets`), `limits.streamIdleTimeoutMs` (moved to `providers.codex.streamIdleTimeoutMs`), `limits.requestTimeoutMs` (moved to `providers.codex.requestTimeoutMs`), and `limits.maxSseEventBytes` (moved to `providers.codex.maxSseEventBytes`), joining the existing strips for `anthropic.streamIdleTimeoutMs`, `limits.connectTimeoutMs`, and `limits.maxConcurrentRequests`. A hand-edited config carrying any of these keys would have killed the relay on every session start — spawned by the `ensure-proxy` hook, with no route back. The `limits` block is now omitted entirely when all its sub-keys are stripped (matching the `anthropic` block's existing omit-when-empty behaviour).
21
+
22
+ **Upgrade**: no action required. If you hand-edited `~/.devflow/proxy-routing.json` to set any of the seven stripped `limits.*` keys, move their values to the 0.4.0 target paths (`limits.maxBufferedBodyBytes`, `anthropic.maxUpstreamSockets`, or the appropriate `providers.codex.*` key); the next `devflow proxy --enable` drops the stale keys for you.
23
+
24
+ **Upgrade (attribution)**: existing installs that carry the devflow-managed `attribution` block — written by prior versions' `templates/settings.json` — keep their suppression. On the next `devflow init` or any `devflow flags` write, the managed block is detected and adopted into the manifest as `suppress-attribution: true` automatically, so no manual action is required. Installs with a custom `attribution` value are also unaffected — the shape guard prevents any modification.
25
+
26
+ ---
27
+
28
+ ## [2.3.0] - 2026-08-31
29
+
30
+ ### Added
31
+ - **`refresh-anchor` ledger op**: post-promotion reinforcement now reaches rendered output. When the Learning agent reinforces an already-anchored observation (sharpening its `pattern`/`details`), calling `refresh-anchor <anchor_id>` re-projects the updated log row through the same `toLedgerRow` projector as `assign-anchor` and re-renders all three `.md` files. Previously, post-promotion sharpening was written to the log but never projected forward, so the rendered entry silently froze at its first-promotion snapshot.
32
+
33
+ ### Changed
34
+ - **`decisions-ledger.jsonl` is now the anchor registry only (ADR-022)**: `decisions-log.jsonl` is the content authority; the ledger holds anchor numbers and `decisions_status` only. Entry content reaches the ledger exclusively through `assign-anchor` (first promotion) and `refresh-anchor` (post-promotion re-projection) via `toLedgerRow`. The Learning agent's previously sanctioned path of editing ledger rows directly is removed — content changes go to the log, then `refresh-anchor` re-projects. Tooling that reads or writes `decisions-ledger.jsonl` directly is affected.
35
+ - **`/resolve` DUPLICATE verdict**: `/resolve` now collapses duplicate cross-reviewer findings via a new `DUPLICATE` triage verdict — resolution-summary counts unique issues, with a `Duplicates Collapsed` statistics row and a `## Duplicates` section for traceability.
36
+
37
+ ### Fixed
38
+ - **Semicolon-safe `details` field parsing**: the decisions formatter now splits `details` into fields using a segment-aware parser (`segmentDetails`) instead of delimiter regexes. The parser recognises a segment as a new field only when it starts with a known key name followed by `:` (anchored to segment start); semicolons inside values are preserved. Fixes four related defects: truncation at the first internal semicolon, unanchored-key false match (e.g. `reissue:` matching `issue:`), first-match-wins hijack (a key name mentioned inside an earlier value would capture the wrong segment), and newline breakage. Measured blast radius on this repo's own ledger: 111 truncated field extractions before the fix, 0 after. **Installed projects' rendered decisions/pitfalls `.md` may show one-time `--check` drift under the new parser — self-heals on the next ledger op.**
39
+ - **Armed double-assign guard**: `assign-anchor` now writes `anchor_id` back to the log row on promotion, enabling the guard that prevents re-anchoring an already-anchored observation. Previously `anchor_id` was never written back, so the guard was permanently inert and running `assign-anchor` twice on the same observation silently minted two anchors.
40
+ - **Pitfall date stamping and render date-purity**: `assign-anchor` now stamps a `date` field on all entry types (decisions and pitfalls). Previously only decision rows received a date stamp, leaving the 7-day protection window permanently inert for all pitfall entries. Render formatters now use `row.date || ''` (D5) instead of reading the clock, making renders deterministic and avoiding phantom date changes on re-render.
41
+ - **Amendments rendering**: `formatAmendmentsLine` renders the `amendments` field as a `- **Amendments**: ...` line in the entry body. Previously the projected `amendments` field was never rendered, so amendment notes were lost at the `.md` level. Index extraction regexes are now line-anchored (`/m` flag) to prevent amendment text that mentions `- **Status**:` or `- **Area**:` from hijacking the extracted values.
42
+ - **Working memory worker staged-write CAS** (closes #306): the background memory worker now writes to `WORKING-MEMORY.md.new` (staged file, never the real path) and compare-and-swaps into place only if `WORKING-MEMORY.md` is byte-identical to the pre-run snapshot. Previously the success check accepted any mtime bump on a file whose line 1 carried the stamp prefix — including a human's own concurrent edit — and on that false success the worker deleted the unprocessed queue batch and touched `.last-refresh-ok`, producing silent loss of captured turns under a healthy freshness marker.
43
+ - **Stamped pre-compact bootstrap**: `pre-compact-memory` now writes a HEAD SHA stamp on line 1 of `WORKING-MEMORY.md` (guarded by a 40-hex validation gate) and lays out the five canonical sections in fixed order. Previously the bootstrap ran without a stamp, producing "synced @ unknown" at the next SessionStart and an incorrect State-A classification that hid any real drift.
44
+ - **Reconciliation-aware worker prompt**: the memory worker prompt now includes bounded git evidence since the last stamp, explicit reconciliation and expiry guidance, and a strict DONE definition (per PF-010). Addresses unbounded carry-forward, conversation-coined labels promoted to durable state, and no-expiry instruction.
45
+ - **State-C orphaned `.processing` visibility**: `session-start-memory`'s State C queue-depth count now includes lines from any orphaned `.pending-turns.processing` file, not just `.pending-turns.jsonl`. Previously an orphaned `.processing` was invisible to the State C detector, so a CONFLICT-requeued batch did not show in the refresh-failing banner.
46
+ - **`/resolve` base branch token**: resolution summaries now render the base branch name instead of a literal `{base}` token. Step 0b was not extracting `base_branch` while the summary template referenced it.
47
+ - **render summary byte counts**: `render-decisions.cjs` now reports file sizes via `Buffer.byteLength()` instead of `String.length`. The em dash separator in index Area fields (U+2014, 3 UTF-8 bytes, 1 JS character) caused the logged index.md size to be 2 bytes per em dash in the rendered index under the old code.
48
+
49
+ ---
50
+
8
51
  ## [2.2.0] - 2026-08-25
9
52
 
10
53
  ### Changed
@@ -1192,6 +1235,8 @@ devflow init
1192
1235
  ---
1193
1236
 
1194
1237
  [Unreleased]: https://github.com/dean0x/devflow/compare/v2.0.0...HEAD
1238
+ [2.4.0]: https://github.com/dean0x/devflow/compare/v2.3.0...v2.4.0
1239
+ [2.3.0]: https://github.com/dean0x/devflow/compare/v2.2.0...v2.3.0
1195
1240
  [2.2.0]: https://github.com/dean0x/devflow/compare/v2.1.0...v2.2.0
1196
1241
  [2.1.0]: https://github.com/dean0x/devflow/compare/v2.0.1...v2.1.0
1197
1242
  [2.0.1]: https://github.com/dean0x/devflow/compare/v2.0.0...v2.0.1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Attribution prompt helpers for devflow init.
3
+ *
4
+ * CLI-layer module (ADR-013): prompt-rendering logic lives in src/cli/commands/,
5
+ * core business logic stays in src/core/.
6
+ *
7
+ * Applies PF-029: the gate is an exported pure predicate with an explicit isTTY guard,
8
+ * so --recommended (flag, no prompt) and the non-TTY fallback keep their promptless
9
+ * contracts and the reachability rule is unit-testable without a terminal.
10
+ * Applies PF-014: runAttributionStep never calls process.exit() or throws — callers
11
+ * own the cancel idiom (p.cancel + process.exit(0)), keeping try/finally cleanup safe.
12
+ *
13
+ * D27: suppress-attribution flag — gates Claude Code's AI-attribution injection.
14
+ * The question is ADVANCED-ONLY; Recommended never asks. See shouldRunAttributionStep.
15
+ *
16
+ * Shared DI seam (PromptOutcome, WizardPromptIO, clackNote, clackSelect) lives in
17
+ * prompt-io.ts — one definition, both wizard modules import from there (ADR-019).
18
+ */
19
+ import { clackNote, clackSelect } from './prompt-io.js';
20
+ // ── Gate predicate ─────────────────────────────────────────────────────────────
21
+ /**
22
+ * Determines whether the attribution wizard step should run for a given init invocation.
23
+ *
24
+ * D27 — ADVANCED-ONLY. The attribution question is reachable from the Advanced path
25
+ * and nowhere else. This DIVERGES DELIBERATELY from shouldRunComplianceStep, which also
26
+ * runs on interactive Recommended: attribution rewrites the user's git history metadata,
27
+ * so Recommended stays a zero-question path and silently applies the seeded value
28
+ * (fresh install: off). Do not "restore symmetry" with the compliance gate.
29
+ *
30
+ * Gate table:
31
+ *
32
+ * --advanced flag / re-init (banner path) / prompt → Advanced → yes
33
+ * Interactive mode-prompt → Recommended → NO (D27 divergence)
34
+ * --recommended flag → no
35
+ * !isTTY (any mode) → no
36
+ *
37
+ * Gating on the mode name is sound here because 'advanced' is only ever resolved on an
38
+ * interactive path (the Advanced branch exit-1s on non-TTY), and the explicit isTTY guard
39
+ * keeps the promptless contracts of --recommended and the non-TTY fallback pinned
40
+ * regardless (PF-029).
41
+ *
42
+ * There is no CLI override for attribution — it is toggled post-install via
43
+ * `devflow flags --enable/--disable suppress-attribution`.
44
+ *
45
+ * Pure predicate — no side effects, fully testable without a TTY.
46
+ */
47
+ export function shouldRunAttributionStep(input) {
48
+ if (!input.isTTY)
49
+ return false;
50
+ return input.mode === 'advanced';
51
+ }
52
+ /**
53
+ * Build the real (clack) AttributionPromptIO adapter.
54
+ * Delegates to the shared clackNote / clackSelect adapters (prompt-io.ts).
55
+ */
56
+ export function buildClackAttributionPrompts() {
57
+ return {
58
+ note: clackNote,
59
+ select: (opts) => clackSelect(opts),
60
+ };
61
+ }
62
+ // ── Seed / apply helpers ───────────────────────────────────────────────────────
63
+ /**
64
+ * Derive the boolean seed for the attribution prompt from the current FlagsRecord.
65
+ *
66
+ * Returns true only when `suppress-attribution` is explicitly set to the boolean
67
+ * true — undefined, null, and false all map to false, giving `p.select` a real
68
+ * boolean rather than an unchecked cast (PF-018: non-vacuous path).
69
+ *
70
+ * Pure function — no side effects, fully testable without a TTY.
71
+ */
72
+ export function attributionSeedFrom(flags) {
73
+ return flags['suppress-attribution'] === true;
74
+ }
75
+ /**
76
+ * Apply the resolved wizard answer back onto the FlagsRecord.
77
+ *
78
+ * Returns a new record with `suppress-attribution` updated to outcome.suppress.
79
+ * Never mutates the input (immutability principle). The caller replaces its local
80
+ * enabledFlags binding with the return value.
81
+ *
82
+ * D27: this is the single merge site for the wizard answer; init.ts must not
83
+ * duplicate the spread inline.
84
+ *
85
+ * Pure function — no side effects, fully testable without a TTY.
86
+ */
87
+ export function applyAttributionAnswer(flags, outcome) {
88
+ return { ...flags, 'suppress-attribution': outcome.suppress };
89
+ }
90
+ /**
91
+ * Run the attribution wizard step.
92
+ *
93
+ * Flow:
94
+ * 1. Note — "Current setting: …" header with context about what the flag does.
95
+ * 2. Enable select — labeled Yes / No with hints (seeded from prior state);
96
+ * p.select is immune to Enter-through muscle memory while still preserving
97
+ * the seeded value (ambient-prompt style — per PF-029).
98
+ *
99
+ * Returns:
100
+ * {kind:'resolved', suppress, messages} — step completed; `suppress` is the chosen
101
+ * boolean; `messages` are emitted by the caller.
102
+ * {kind:'cancelled'} — user pressed Escape; caller runs p.cancel + process.exit(0).
103
+ *
104
+ * Invariants (PF-014):
105
+ * - Never calls process.exit(), never throws.
106
+ * - All I/O is routed through the `prompts` parameter (injectable for tests).
107
+ */
108
+ export async function runAttributionStep(opts) {
109
+ const { seed, prompts } = opts;
110
+ const currentStr = seed ? 'suppressed' : 'shown (default)';
111
+ // security-02: name the destructive branch (Yes) BEFORE the user consents.
112
+ // ADR-024 corollary (b): turning the flag ON replaces any existing attribution
113
+ // value, including a custom one — this is deliberate. Only the exact
114
+ // devflow-managed shape {"commit":"","pr":""} is removed on disable.
115
+ // security-04: surface the org AI-disclosure-policy dimension as a note (not a gate).
116
+ prompts.note(`Current setting: ${currentStr}\n\n` +
117
+ 'Choosing Yes REPLACES any existing \`attribution\` value in settings.json,\n' +
118
+ 'including a custom one, with {"commit":"","pr":""}.\n' +
119
+ 'Choosing No leaves a custom value untouched — only the exact\n' +
120
+ 'devflow-managed block is ever removed on disable.\n' +
121
+ 'Some organisations require machine-readable AI-authorship disclosure\n' +
122
+ '— check your policy before enabling.\n\n' +
123
+ 'Toggle any time with: devflow flags --enable suppress-attribution', 'AI Attribution');
124
+ const enableOutcome = await prompts.select({
125
+ message: 'Suppress AI attribution in commits and PRs?',
126
+ options: [
127
+ { value: true, label: 'Yes', hint: 'hides Claude attribution labels in git history' },
128
+ { value: false, label: 'No', hint: 'keeps Claude attribution labels (default)' },
129
+ ],
130
+ initialValue: seed,
131
+ });
132
+ if (enableOutcome.kind === 'cancel')
133
+ return { kind: 'cancelled' };
134
+ const suppress = enableOutcome.value;
135
+ const text = suppress
136
+ ? 'Attribution: suppressed — disable with devflow flags --disable suppress-attribution'
137
+ : 'Attribution: shown (default)';
138
+ return {
139
+ kind: 'resolved',
140
+ suppress,
141
+ messages: [{ level: suppress ? 'success' : 'info', text }],
142
+ };
143
+ }
144
+ //# sourceMappingURL=attribution-prompts.js.map
@@ -9,9 +9,13 @@
9
9
  * no prompt) and the non-TTY fallback preserve their promptless contracts.
10
10
  * Applies PF-014: runComplianceStep never calls process.exit() or throws — callers
11
11
  * own the cancel idiom (p.cancel + process.exit(0)), keeping try/finally cleanup safe.
12
+ *
13
+ * Shared DI seam (PromptOutcome, WizardPromptIO, clackNote, clackSelect) lives in
14
+ * prompt-io.ts — one definition, both wizard modules import from there (ADR-019).
12
15
  */
13
16
  import * as p from '@clack/prompts';
14
17
  import { COMPLIANCE_FRAMEWORKS } from '../../core/compliance.js';
18
+ import { clackNote, clackSelect } from './prompt-io.js';
15
19
  // ── Shared prompt content ──────────────────────────────────────────────────────
16
20
  /** Message shown on the compliance framework multiselect prompt. */
17
21
  export const FRAMEWORK_SELECT_MESSAGE = 'Select compliance frameworks (Enter to skip — generic controls only)';
@@ -75,21 +79,13 @@ export function shouldRunComplianceStep(input) {
75
79
  }
76
80
  /**
77
81
  * Build the real (clack) CompliancePromptIO adapter.
82
+ * Delegates the shared note + select to the shared adapters (prompt-io.ts).
78
83
  * Translates clack's cancel symbol into the PromptOutcome discriminated union.
79
84
  */
80
85
  export function buildClackCompliancePrompts() {
81
86
  return {
82
- note: (message, title) => p.note(message, title),
83
- select: async (opts) => {
84
- const result = await p.select({
85
- message: opts.message,
86
- options: opts.options,
87
- initialValue: opts.initialValue,
88
- });
89
- if (p.isCancel(result))
90
- return { kind: 'cancel' };
91
- return { kind: 'value', value: result };
92
- },
87
+ note: clackNote,
88
+ select: (opts) => clackSelect(opts),
93
89
  multiselect: async (opts) => {
94
90
  const result = await p.multiselect({
95
91
  message: opts.message,
@@ -13,7 +13,7 @@
13
13
  * beside init.ts in src/cli/commands/ rather than in src/core/ (which holds
14
14
  * agent-neutral, target-agnostic utilities).
15
15
  */
16
- import { resolveExistingViewMode, FLAG_REGISTRY, defaultValueOf, readViewMode, } from '../../core/flags.js';
16
+ import { resolveExistingViewMode, settingHoldsManagedShape, FLAG_REGISTRY, defaultValueOf, readViewMode, } from '../../core/flags.js';
17
17
  import { partitionSelectablePlugins } from '../../core/plugins.js';
18
18
  /** Registry defaults — all features enabled except proxy (advanced-only, off by default). */
19
19
  export const FEATURE_DEFAULTS = {
@@ -160,12 +160,36 @@ export function resolveSeedPlugins(manifestPlugins, knownPlugins, allPlugins) {
160
160
  }
161
161
  return { workflowPlugins, languagePlugins };
162
162
  }
163
+ /**
164
+ * Extract the attribution suppression state from a settings JSON string.
165
+ *
166
+ * Returns `true` when the exact devflow-managed attribution shape is present —
167
+ * delegating to `settingHoldsManagedShape('suppress-attribution')`, which is the
168
+ * single place that decides "on-disk value equals the managed shape" (consistency-01).
169
+ * Returns `undefined` when the block is absent, holds a custom value, or JSON is
170
+ * malformed — callers fall through to the manifest FlagsRecord entry or the registry
171
+ * default.
172
+ *
173
+ * Return type is `true | undefined` (typescript-06): settings.json can signal ON or
174
+ * nothing — never explicitly OFF. That asymmetry is load-bearing for seeding priority:
175
+ * `undefined` lets the manifest entry or the registry default win, while `true` is an
176
+ * unambiguous "this key was devflow-managed and active".
177
+ *
178
+ * Mirrors resolveExistingViewMode: returns undefined on malformed JSON, absent key,
179
+ * or any non-devflow attribution value.
180
+ *
181
+ * Pure function — no I/O, no side effects.
182
+ */
183
+ export function resolveExistingAttributionSuppression(settingsJson) {
184
+ return settingHoldsManagedShape(settingsJson, 'suppress-attribution') ? true : undefined;
185
+ }
163
186
  /**
164
187
  * Compose the full init seed from manifest, project config, settings, and registry.
165
188
  *
166
189
  * view-mode priority: existing settings.json (non-default) → manifest → 'default'.
167
- * The resolved view mode is encoded into flags['view-mode'] so all flag state lives
168
- * in one FlagsRecord (applying PF-015: fold before strip — the fold happens here).
190
+ * suppress-attribution priority: settings.json exact devflow shape → manifest → false.
191
+ * All flag overrides are encoded into flags so all flag state lives in one FlagsRecord
192
+ * (applying PF-015: fold before strip — the fold happens here).
169
193
  *
170
194
  * This is the single composition point; callers (init.ts hoist block) call this
171
195
  * once and pass `seed` down to prompt wiring.
@@ -194,9 +218,26 @@ export function resolveInitSeed(seedManifest, seedConfig, settingsSnapshot, plug
194
218
  else {
195
219
  resolvedViewMode = 'default'; // fall back to neutral
196
220
  }
221
+ // Encode resolved attribution suppression (D27) into flags['suppress-attribution'].
222
+ // Priority: settings.json exact devflow shape → manifest FlagsRecord entry → false.
223
+ // resolveExistingAttributionSuppression returns true only for the exact managed shape
224
+ // {"commit":"","pr":""}; custom values return undefined so the manifest entry wins.
225
+ const existingAttr = resolveExistingAttributionSuppression(settingsSnapshot);
226
+ const resolvedSuppressAttr = existingAttr !== undefined
227
+ ? existingAttr // settings.json exact shape wins
228
+ : flags['suppress-attribution'] === true; // manifest or registry default
197
229
  // Return a fresh spread rather than mutating flags in place — keeps this function pure
198
230
  // per the module docblock and avoids aliasing if the caller inspects seed.flags.
199
- return { features, flags: { ...flags, 'view-mode': resolvedViewMode }, workflowPlugins, languagePlugins };
231
+ return {
232
+ features,
233
+ flags: {
234
+ ...flags,
235
+ 'view-mode': resolvedViewMode,
236
+ 'suppress-attribution': resolvedSuppressAttr,
237
+ },
238
+ workflowPlugins,
239
+ languagePlugins,
240
+ };
200
241
  }
201
242
  /**
202
243
  * Resolve the three seed inputs under the --reset gate.
@@ -18,7 +18,7 @@ import { addCaptureHooks, removeCaptureHooks } from './capture.js';
18
18
  import { removeDreamHook } from './legacy-hooks.js';
19
19
  import { addProxyHooks, removeProxyHooks, applyProxyEnv, stripProxyEnv, runProxyPreflight, buildRealPreflightDeps } from './proxy.js';
20
20
  import { reapplyAgentMapping, readAgentMapping } from '../../core/agent-models.js';
21
- import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, DEFAULT_PROXY_PORT } from '../../core/proxy-state.js';
21
+ import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, DEFAULT_PROXY_PORT, proxyJsonExists } from '../../core/proxy-state.js';
22
22
  import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-cleanup.js';
23
23
  // Settings/HookMatcher types used by hook utilities — each in their own module
24
24
  import { addHudStatusLine, removeHudStatusLine } from './hud.js';
@@ -30,6 +30,7 @@ import { writeConfig, readConfigIfPresent } from '../../core/feature-config.js';
30
30
  import { resolveInitSeed, applyCliToggles, resolveResetGatedInputs } from './init-seed.js';
31
31
  import { parseFrameworkList, normalizeFrameworks } from '../../core/compliance.js';
32
32
  import { formatComplianceSummary, shouldRunComplianceStep, runComplianceStep, buildClackCompliancePrompts, } from './compliance-prompts.js';
33
+ import { shouldRunAttributionStep, runAttributionStep, buildClackAttributionPrompts, applyAttributionAnswer, attributionSeedFrom, } from './attribution-prompts.js';
33
34
  import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
34
35
  import { getPendingTurnsPath, getPendingTurnsProcessingPath } from '../../core/project-paths.js';
35
36
  import * as os from 'os';
@@ -551,6 +552,9 @@ export const initCommand = new Command('init')
551
552
  // Step messages not emitted here — the Recommended summary note (below) already
552
553
  // prints the Compliance line from complianceSummary via formatComplianceSummary.
553
554
  }
555
+ // No attribution step here: the suppress-attribution question is Advanced-only (D27).
556
+ // Recommended silently carries the seeded value in enabledFlags — fresh installs get
557
+ // the registry default (off), re-inits get prior state. See shouldRunAttributionStep.
554
558
  // Apply explicit CLI toggles on top of the seed.
555
559
  // Precedence: explicit CLI flag > wizard result > seed value (prior state > registry default).
556
560
  // proxy is included: --proxy/--no-proxy CLI flags override the seed in non-interactive mode.
@@ -793,6 +797,41 @@ export const initCommand = new Command('init')
793
797
  // No third case in practice: on this path the predicate only returns false for a
794
798
  // CLI override (isTTY is guaranteed true by the non-TTY guard above). If it ever
795
799
  // did, the seed values assigned at declaration stand — which is the right default.
800
+ // Attribution feature (after compliance, before flags). This is the ONLY call site —
801
+ // the attribution question is Advanced-only (D27); the Recommended path never asks and
802
+ // silently carries the seeded value. The gate stays an explicit predicate call so the
803
+ // documented gate table in attribution-prompts.ts remains the single authority.
804
+ // isTTY is guaranteed true here (the non-TTY guard above exit-1'd); passing it keeps
805
+ // the promptless contract enforced at the predicate rather than by position (PF-029).
806
+ if (shouldRunAttributionStep({
807
+ // D27-GATE: bind to the resolved mode so all four documented gate-table rows
808
+ // are reachable and the predicate — not lexical placement — enforces Advanced-only.
809
+ // Using useRecommended ? 'recommended' : 'advanced' makes the gate testable from
810
+ // both sides and prevents the Recommended path from accidentally running the step
811
+ // if this block is ever repositioned (applies PF-029).
812
+ mode: useRecommended ? 'recommended' : 'advanced',
813
+ isTTY: process.stdin.isTTY,
814
+ })) {
815
+ const attributionStep = await runAttributionStep({
816
+ // attributionSeedFrom keeps the seed a real boolean regardless of the stored
817
+ // FlagsRecord value type — undefined/null/false all map to false (PF-018).
818
+ seed: attributionSeedFrom(enabledFlags),
819
+ prompts: buildClackAttributionPrompts(),
820
+ });
821
+ if (attributionStep.kind === 'cancelled') {
822
+ p.cancel('Installation cancelled.');
823
+ process.exit(0);
824
+ }
825
+ // D27: applyAttributionAnswer is the single merge site for the wizard answer.
826
+ enabledFlags = applyAttributionAnswer(enabledFlags, attributionStep);
827
+ // Advanced path emits an outcome line (mirrors compliance step pattern).
828
+ for (const msg of attributionStep.messages) {
829
+ if (msg.level === 'success')
830
+ p.log.success(msg.text);
831
+ else
832
+ p.log.info(msg.text);
833
+ }
834
+ }
796
835
  /**
797
836
  * D40: init applies seeded flag defaults non-interactively. Flags are customized
798
837
  * exclusively via `devflow flags`; re-init preserves existing values and adopts
@@ -1411,14 +1450,18 @@ export const initCommand = new Command('init')
1411
1450
  content = JSON.stringify(parsedSettings, null, 2) + '\n';
1412
1451
  }
1413
1452
  // Proxy env: ANTHROPIC_BASE_URL strip-then-add, scoped to managed port.
1414
- // Read proxy.json to learn which port we own — only that URL is stripped.
1415
- // A user's own localhost gateway on any other port is preserved.
1453
+ // D-STRIP-1: only strip when proxy.json exists — evidence that Devflow previously
1454
+ // wrote ANTHROPIC_BASE_URL. Without this gate, a fresh init on a machine where
1455
+ // DEFAULT_PROXY_PORT (4141) happens to be a user's own gateway (LiteLLM etc.)
1456
+ // would silently delete both their URL and the window-enforcement var.
1416
1457
  // Invariant: proxy.json always reflects the final settled state after the
1417
1458
  // preflight block above — all paths that force proxyEnabled=false also write
1418
1459
  // proxy.json enabled:false (avoids PF-015), so managedPort == effectivePort.
1419
- const proxyStateForStrip = await readProxyState(devflowDir);
1420
- const managedPort = proxyStateForStrip.ok ? proxyStateForStrip.value.port : DEFAULT_PROXY_PORT;
1421
- content = stripProxyEnv(content, managedPort);
1460
+ if (await proxyJsonExists(devflowDir)) {
1461
+ const proxyStateForStrip = await readProxyState(devflowDir);
1462
+ const managedPort = proxyStateForStrip.ok ? proxyStateForStrip.value.port : DEFAULT_PROXY_PORT;
1463
+ content = stripProxyEnv(content, managedPort);
1464
+ }
1422
1465
  if (proxyEnabled)
1423
1466
  content = applyProxyEnv(content, effectivePort);
1424
1467
  if (content !== original) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared wizard prompt-IO seam for devflow init wizard steps.
3
+ *
4
+ * ADR-019 corollary (one-definition seam): PromptOutcome and WizardPromptIO
5
+ * were byte-identical duplicates across attribution-prompts.ts and
6
+ * compliance-prompts.ts (architecture-03 / consistency-06). They are defined
7
+ * ONCE here and re-used via import.
8
+ *
9
+ * D-PROMPT-IO: WizardPromptIO is the base DI seam for all two-action wizard
10
+ * steps (note + boolean select). Modules that add a third prompt extend this
11
+ * interface with an intersection type (e.g. CompliancePromptIO).
12
+ */
13
+ import * as p from '@clack/prompts';
14
+ // ── Shared clack adapters ─────────────────────────────────────────────────────
15
+ /** Real clack adapter for the note prompt. */
16
+ export function clackNote(message, title) {
17
+ p.note(message, title);
18
+ }
19
+ /**
20
+ * Real clack adapter for a select prompt, generic over the option value type T.
21
+ *
22
+ * typescript-05: no `as T` cast on the result path. `p.select<T>` returns
23
+ * `Promise<symbol | T>`; `p.isCancel` is a `(value: unknown) => value is symbol`
24
+ * guard that narrows away the cancel branch, leaving `result: T` without a cast.
25
+ * An `as T` here would mask a future widening of the library's return type.
26
+ *
27
+ * The `as unknown as SelectOptions<T>` on the input is a safe bridge for the
28
+ * unresolved conditional type `Option<T>` — our shape satisfies both branches
29
+ * (Primitive: label optional; non-Primitive: label required) and is strictly
30
+ * narrower, so no value-type information is lost.
31
+ */
32
+ export async function clackSelect(opts) {
33
+ const result = await p.select(opts);
34
+ if (p.isCancel(result))
35
+ return { kind: 'cancel' };
36
+ return { kind: 'value', value: result };
37
+ }
38
+ //# sourceMappingURL=prompt-io.js.map
@@ -22,7 +22,7 @@ import * as https from 'https';
22
22
  import { spawn as cpSpawn } from 'child_process';
23
23
  import * as p from '@clack/prompts';
24
24
  import color from 'picocolors';
25
- import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, proxyBaseUrl, resolveProxyBin, DEFAULT_PROXY_PORT, } from '../../core/proxy-state.js';
25
+ import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, proxyBaseUrl, proxyJsonExists, resolveProxyBin, DEFAULT_PROXY_PORT, } from '../../core/proxy-state.js';
26
26
  import { syncManifestFeature, readManifest } from '../../core/manifest.js';
27
27
  import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
28
28
  import { scrubChildEnv, openProxyLog, rotateProxyLogIfLarge } from '../../core/proxy-log.js';
@@ -149,9 +149,11 @@ function _stripProxyEnvFromObject(settings, managedPort) {
149
149
  const env = s.env;
150
150
  if (!env)
151
151
  return false;
152
- // Devflow is the only producer of this var — always remove it, regardless of whether
153
- // the URL is still ours. Port-scoping protects a FOREIGN url value; there is no
154
- // foreign value of this key to protect. (applies PF-015, ADR-003)
152
+ // D-STRIP-1: every REMOVAL caller (init, uninstall, runDisable) first proves Devflow
153
+ // managed the proxy — `proxyJsonExists()` — and the enable caller is taking ownership
154
+ // of the key anyway, so reaching this line means the value is Devflow's to remove.
155
+ // Removal is therefore unconditional: unlike ANTHROPIC_BASE_URL (port-scoped below),
156
+ // this key has no foreign value to protect. (applies PF-015, ADR-003)
155
157
  const hadWindowVar = env[UNKNOWN_MODEL_WINDOW_ENV] !== undefined;
156
158
  delete env[UNKNOWN_MODEL_WINDOW_ENV];
157
159
  let removedUrl = false;
@@ -284,6 +286,31 @@ export function applyDisableToSettings(settings, managedPort) {
284
286
  const strippedEnv = _stripProxyEnvFromObject(settings, managedPort);
285
287
  return removedHooks || strippedEnv;
286
288
  }
289
+ /**
290
+ * Settings teardown for every path that turns the proxy off: `devflow proxy --disable`
291
+ * and `devflow uninstall`.
292
+ *
293
+ * D-STRIP-1: the two removals answer to different evidence, so they are gated
294
+ * separately.
295
+ * - Hooks are Devflow's own artifacts — removed unconditionally. Leaving them behind
296
+ * points later sessions at a hook script that no longer exists, and makes a re-run
297
+ * of an interrupted uninstall unable to clean up.
298
+ * - The env vars (ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT)
299
+ * are stripped only against `managedPort`, which the caller supplies from an existing
300
+ * `proxy.json` — the one piece of evidence that Devflow ever wrote them. Pass
301
+ * `undefined` when that file is absent: the values then belong to a gateway Devflow
302
+ * never managed (a user's own LiteLLM on 4141, say) and must survive untouched.
303
+ *
304
+ * When `managedPort` is defined this delegates to `applyDisableToSettings`, so the
305
+ * both-operations invariant documented there still holds.
306
+ *
307
+ * Mutates settings in place. Returns true when any change was made.
308
+ */
309
+ export function applyProxyTeardownToSettings(settings, managedPort) {
310
+ if (managedPort === undefined)
311
+ return removeProxyHooks(settings);
312
+ return applyDisableToSettings(settings, managedPort);
313
+ }
287
314
  /**
288
315
  * Check whether the ensure-proxy hook is registered on at least one event.
289
316
  * Returns true if present on either SessionStart or UserPromptSubmit.
@@ -316,6 +343,47 @@ export function isOurRelayBody(body) {
316
343
  return false;
317
344
  }
318
345
  }
346
+ /**
347
+ * Preflight check ④ — settings.json readable and parseable, no foreign
348
+ * ANTHROPIC_BASE_URL, plus the non-fatal ANTHROPIC_API_KEY warning.
349
+ *
350
+ * Extracted so the adopted-relay path and the free-port path run the identical
351
+ * check (D-EFR-5): a healthy relay on the port must not buy an exemption from the
352
+ * foreign-gateway refusal. Keeping it in one function is what stops the two paths
353
+ * from drifting on which sub-checks they apply or which message they return.
354
+ *
355
+ * `swallowSettingsReadError` semantics are preserved by the caller-supplied dep:
356
+ * when that flag is set (init), readSettingsJson() resolves to '{}' on I/O failure
357
+ * instead of rejecting, so the read-failure branch is reachable only from runEnable.
358
+ */
359
+ async function checkSettingsEnv(deps, port) {
360
+ let settingsJson;
361
+ try {
362
+ settingsJson = await deps.readSettingsJson();
363
+ }
364
+ catch {
365
+ return Err('Could not read settings.json — check file permissions');
366
+ }
367
+ let parsedSettings;
368
+ try {
369
+ parsedSettings = JSON.parse(settingsJson);
370
+ }
371
+ catch {
372
+ return Err('settings.json is malformed — fix it before enabling the proxy');
373
+ }
374
+ if (readProxyEnvState(settingsJson, port) === 'foreign') {
375
+ return Err('An existing ANTHROPIC_BASE_URL in settings.json points to a different gateway — Devflow will not overwrite it');
376
+ }
377
+ // API key warning (non-fatal)
378
+ const envBlock = parsedSettings.env;
379
+ if (typeof envBlock === 'object' &&
380
+ envBlock !== null &&
381
+ !Array.isArray(envBlock) &&
382
+ typeof envBlock.ANTHROPIC_API_KEY === 'string') {
383
+ deps.onWarn?.('ANTHROPIC_API_KEY is set in settings.json — requests will use that key through the local relay');
384
+ }
385
+ return Ok(undefined);
386
+ }
319
387
  /**
320
388
  * Run preflight checks before enabling the Devflow proxy.
321
389
  *
@@ -324,6 +392,8 @@ export function isOurRelayBody(body) {
324
392
  * ② ~/.codex/auth.json exists.
325
393
  * ③ Port probe: free → OK; accepting → health check → adopt or fail.
326
394
  * ④ settings.json parseable; ANTHROPIC_BASE_URL not pointing elsewhere; API key warn.
395
+ * Runs on BOTH outcomes of ③ that can proceed — adopted relay and free port
396
+ * (D-EFR-5) — via the shared `checkSettingsEnv` helper.
327
397
  *
328
398
  * Doctor is deliberately excluded from preflight: the relay's doctor subcommand
329
399
  * probes the relay port — a not-yet-started relay makes that probe fail (exit 1). A
@@ -349,6 +419,12 @@ export async function runProxyPreflight(port, codexAuthPath, configPath, logPath
349
419
  // Port is up — check health identity
350
420
  const healthResult = await deps.httpGet(`${proxyBaseUrl(port)}/__subswitch/health`, PROBE_TIMEOUT_MS);
351
421
  if (healthResult.ok && isOurRelayBody(healthResult.value)) {
422
+ // D-EFR-5: run check ④ BEFORE the adopted early-return. The old order
423
+ // (adopted-return first, settings check only when the port was free) let a
424
+ // healthy relay on the port silently skip the foreign-gateway refusal entirely.
425
+ const settingsCheck = await checkSettingsEnv(deps, port);
426
+ if (!settingsCheck.ok)
427
+ return Err(settingsCheck.error);
352
428
  return Ok({ binPath, npxWarning, adopted: true });
353
429
  }
354
430
  // Port accepting but health timed out, failed, or not our relay
@@ -356,32 +432,9 @@ export async function runProxyPreflight(port, codexAuthPath, configPath, logPath
356
432
  }
357
433
  // Port refused — free to proceed
358
434
  // ④ Settings.json check
359
- let settingsJson;
360
- try {
361
- settingsJson = await deps.readSettingsJson();
362
- }
363
- catch {
364
- return Err('Could not read settings.json — check file permissions');
365
- }
366
- let parsedSettings;
367
- try {
368
- parsedSettings = JSON.parse(settingsJson);
369
- }
370
- catch {
371
- return Err('settings.json is malformed — fix it before enabling the proxy');
372
- }
373
- const envState = readProxyEnvState(settingsJson, port);
374
- if (envState === 'foreign') {
375
- return Err('An existing ANTHROPIC_BASE_URL in settings.json points to a different gateway — Devflow will not overwrite it');
376
- }
377
- // API key warning (non-fatal)
378
- const envBlock = parsedSettings.env;
379
- if (typeof envBlock === 'object' &&
380
- envBlock !== null &&
381
- !Array.isArray(envBlock) &&
382
- typeof envBlock.ANTHROPIC_API_KEY === 'string') {
383
- deps.onWarn?.('ANTHROPIC_API_KEY is set in settings.json — requests will use that key through the local relay');
384
- }
435
+ const settingsCheck = await checkSettingsEnv(deps, port);
436
+ if (!settingsCheck.ok)
437
+ return Err(settingsCheck.error);
385
438
  return Ok({ binPath, npxWarning, adopted: false });
386
439
  }
387
440
  // ─── Production dependency implementations ────────────────────────────────────
@@ -1381,6 +1434,13 @@ async function runDisable() {
1381
1434
  // applyDisableToSettings strips ANTHROPIC_BASE_URL only when the URL
1382
1435
  // port matches the port Devflow manages — callers must supply it. Reading
1383
1436
  // proxy.json here also consolidates state for Step 2 below.
1437
+ //
1438
+ // D-STRIP-1: readProxyState() cannot distinguish "file absent" from "file present
1439
+ // with the default port", so the env strip is gated on the file's existence — the
1440
+ // only evidence that Devflow ever wrote those vars. Must be read before Step 2
1441
+ // creates the file. Hook removal is NOT gated: our hooks are ours to remove on
1442
+ // every path.
1443
+ const proxyManaged = await proxyJsonExists(devflowDir);
1384
1444
  const priorStateResult = await readProxyState(devflowDir);
1385
1445
  const priorState = priorStateResult.ok ? priorStateResult.value : null;
1386
1446
  const managedPort = priorState?.port ?? DEFAULT_PROXY_PORT;
@@ -1401,7 +1461,7 @@ async function runDisable() {
1401
1461
  process.exitCode = 1;
1402
1462
  return;
1403
1463
  }
1404
- const changed = applyDisableToSettings(parsedSettings, managedPort);
1464
+ const changed = applyProxyTeardownToSettings(parsedSettings, proxyManaged ? managedPort : undefined);
1405
1465
  if (changed) {
1406
1466
  // Guard ENOSPC/EACCES — unhandled rejection leaves proxy in partial state
1407
1467
  try {