devflow-kit 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ 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.1.0] - 2026-08-25
9
+
10
+ ### Added
11
+ - **Typed flag registry**: the flag surface is now a discriminated-union registry of 28 Claude Code flags — `boolean`, `enum`, `number`, and `string` kinds with per-flag validation, defaults, and display metadata. Eight new upstream-verified flags including `max-concurrent-subagents` (devflow default 40; upstream 20), `subagent-spawn-depth`, `workflow-size-guideline`, `goal-checkin-minutes`, `default-model`, `spellcheck`, and `enable-todo-tools`.
12
+ - **Flags editor TUI**: bare `devflow flags` on a TTY opens a full-keyboard settings-page editor rendering inline in the scroll buffer (no alt-screen), with effective-value display and per-flag hint blurbs. The agents view now runs on the same generic TUI shell.
13
+ - **Typed flags CLI**: `devflow flags --list/--status/--enable/--disable/--set/--unset` with input validation; `--enable/--disable` are boolean-only — non-boolean flags route through `--set`.
14
+
15
+ ### Changed
16
+ - **Init seeding**: both init paths apply seeded flag values non-interactively — fresh installs get registry defaults; re-inits preserve existing values and adopt defaults only for newly added flags. The flags editor step was removed from the Advanced wizard.
17
+ - **`viewMode` folded into the registry** as the `view-mode` enum flag (`default|verbose|focus`); the `viewMode` settings key is written only when non-default. Old `flags: string[]` manifests heal in-reader on first read — no migration entry needed.
18
+
19
+ ### Fixed
20
+ - **viewMode silently lost on re-init**: `resolveExistingViewMode` now runs before `stripFlags` in the settings apply block; the prior order stripped `viewMode` from settings before reading it (PF-015).
21
+ - **Published bin not executable**: `prepublishOnly` now sets the execute bit on `dist/cli.js` — the bin shipped as 0644, breaking npm 6 and constrained environments (Docker non-root users, some CI runners) that do not auto-chmod on install.
22
+
23
+ ### Breaking Changes
24
+ - **Bare `devflow flags` on non-TTY** now prints a status table and exits 1 (was: usage line, exit 0). Scripts should call `devflow flags --status`.
25
+ - **Seven settings.json/env keys become Devflow-managed**: `ANTHROPIC_DEFAULT_MODEL`, `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS`, `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH`, `CLAUDE_CODE_GOAL_CHECKIN_MINUTES`, `CLAUDE_CODE_ENABLE_TODO_TOOLS` (env); `workflowSizeGuideline`, `spellcheck` (settings). Hand-set values are preserved — adopted into the manifest on the next `devflow init` or flags mutation; `max-concurrent-subagents` adopts the devflow default of 40 only when the key is absent. All keys are removed on `devflow uninstall`.
26
+
27
+ ---
28
+
8
29
  ## [2.0.1] - 2026-08-23
9
30
 
10
31
  ### Changed
@@ -1163,6 +1184,7 @@ devflow init
1163
1184
  ---
1164
1185
 
1165
1186
  [Unreleased]: https://github.com/dean0x/devflow/compare/v2.0.0...HEAD
1187
+ [2.1.0]: https://github.com/dean0x/devflow/compare/v2.0.1...v2.1.0
1166
1188
  [2.0.1]: https://github.com/dean0x/devflow/compare/v2.0.0...v2.0.1
1167
1189
  [2.0.0]: https://github.com/dean0x/devflow/compare/v1.8.3...v2.0.0
1168
1190
  [1.8.3]: https://github.com/dean0x/devflow/compare/v1.8.2...v1.8.3
@@ -22,7 +22,8 @@
22
22
  * EFFORT : 13
23
23
  * STATE : 14
24
24
  */
25
- import { bold, dim, green, yellow, cyan, gray, truncate, stripAnsi, } from '../../hud/colors.js';
25
+ import { bold, dim, green, yellow, cyan, gray, stripAnsi, } from '../../core/ansi.js';
26
+ import { padToVisible, truncateVisible, sanitizeCell } from '../tui/cells.js';
26
27
  import { isDirtyModel, isDirtyEffort, unsavedCount, isOffCycle, rowState, } from './state.js';
27
28
  import { AGENT_STATE_LABELS } from '../../core/agent-state.js';
28
29
  // ---------------------------------------------------------------------------
@@ -43,7 +44,7 @@ const COL_MODEL = 32;
43
44
  const COL_EFFORT = 13;
44
45
  const COL_STATE = 14;
45
46
  // ---------------------------------------------------------------------------
46
- // Name formatter — TUI only (Fix 4)
47
+ // Name formatter — TUI only
47
48
  // ---------------------------------------------------------------------------
48
49
  /**
49
50
  * Title-case each hyphen-separated segment of the agent name for TUI display.
@@ -63,49 +64,13 @@ export function formatAgentName(name) {
63
64
  .map(seg => (seg.length === 0 ? seg : seg[0].toUpperCase() + seg.slice(1)))
64
65
  .join('-');
65
66
  }
66
- // ---------------------------------------------------------------------------
67
- // Cell renderers (pure, return styled string)
68
- // ---------------------------------------------------------------------------
69
- function padToVisible(s, width) {
70
- // Pad by visible length (strip ANSI, then pad with spaces).
71
- const visible = stripAnsi(s);
72
- const padding = Math.max(0, width - visible.length);
73
- return s + ' '.repeat(padding);
74
- }
75
- function truncateVisible(s, maxWidth) {
76
- const raw = stripAnsi(s);
77
- if (raw.length <= maxWidth)
78
- return s;
79
- // Re-truncate the unstyled version and rebuild — simpler than ANSI-aware slice.
80
- return truncate(raw, maxWidth);
81
- }
82
- /** Layout-breaking whitespace that stripAnsi deliberately preserves. */
83
- const LAYOUT_BREAKING_WS = /[\t\n]/g;
84
- /**
85
- * Sanitize an untrusted string for a fixed-width TUI cell.
86
- *
87
- * stripAnsi strips escape sequences and C0 controls but, by contract, KEEPS
88
- * TAB (\x09) and LF (\x0a) — correct for its own callers, wrong for a cell in
89
- * a fixed-width frame. Orphan row names are arbitrary JSON keys read from
90
- * agent-models.json, so neither is hypothetical:
91
- * - LF emits a newline inside a frame line, breaking renderFrame's
92
- * one-string-per-terminal-line contract and desyncing terminal.ts's
93
- * cursor arithmetic (it writes ERASE_EOL + '\n' per returned line).
94
- * - TAB measures as one character in padToVisible but occupies up to eight
95
- * terminal columns, so every column to its right is misaligned.
96
- * Both collapse to a single space; the raw key is untouched, so the save-path
97
- * merge still targets the real mapping key.
98
- */
99
- function sanitizeCell(s) {
100
- return stripAnsi(s).replace(LAYOUT_BREAKING_WS, ' ');
101
- }
102
67
  /**
103
68
  * Render the model cell for a given row, considering cursor/active/dirty state.
104
69
  *
105
- * Three branches (Fix 1 — alias annotation removed):
70
+ * Three branches:
106
71
  * 1. configuredModel === 'default' → "default (shippedDefault)" [+ dormant hint]
107
72
  * 2. off-cycle pin → "model (unavailable)"
108
- * 3. in-cycle model → bare name (aliases already rendered as picker names by buildRow)
73
+ * 3. in-cycle model → bare name (aliases stored as picker names by buildRow)
109
74
  *
110
75
  * Off-cycle pin (AC-F4): when configuredModel is absent from modelCycle
111
76
  * (retired/unavailable model), show "model (unavailable)".
@@ -208,7 +173,9 @@ function renderStateCell(row, proxyEnabled, maxWidth) {
208
173
  */
209
174
  export function renderFrame(state, dims) {
210
175
  const { rows, cursor, activeField, viewportOffset, proxyEnabled, modelCycle, } = state;
211
- const viewportHeight = Math.max(MIN_VIEWPORT, dims.rows - FIXED_ROWS);
176
+ // state.viewportHeight is the single owner — clamped to a MIN so tests that
177
+ // set viewportHeight explicitly render exactly that many data rows.
178
+ const viewportHeight = Math.max(MIN_VIEWPORT, state.viewportHeight);
212
179
  // Column widths — shrink gracefully at narrow terminals.
213
180
  const totalContent = 2 + COL_AGENT + COL_MODEL + COL_EFFORT + COL_STATE; // prefix + 4 cols
214
181
  const scale = Math.min(1, dims.cols / Math.max(totalContent, 1));
@@ -253,7 +220,7 @@ export function renderFrame(state, dims) {
253
220
  // Sanitize the name — mandatory for orphan rows (arbitrary JSON keys from
254
221
  // agent-models.json may contain escape sequences, newlines or tabs injected
255
222
  // by a hostile file).
256
- // Exactly ONE call site for formatAgentName (Fix 4): TUI only; --list is lowercase.
223
+ // formatAgentName is TUI-only; --list uses the raw lowercase name.
257
224
  const safeName = formatAgentName(sanitizeCell(row.name));
258
225
  const nameCell = padToVisible(isCursor ? bold(truncateVisible(safeName, agentW)) : truncateVisible(safeName, agentW), agentW);
259
226
  const modelCell = padToVisible(renderModelCell({
@@ -1,74 +1,24 @@
1
1
  /**
2
- * Thin impure shell for the devflow agents TUI.
2
+ * Thin adapter — devflow agents TUI shell over the generic runTui driver.
3
3
  *
4
4
  * applies ADR-013: impure I/O shell in CLI layer; pure logic lives in state.ts/render.ts.
5
5
  * avoids PF-014: all cleanup wired via Promise resolve — never process.exit() inside
6
6
  * a finally-guarded scope. Cleanup is idempotent and runs on save, cancel,
7
7
  * SIGINT, SIGTERM, and keypress limit exhaustion.
8
+ * avoids PF-017: this is the thin adapter, not a copy of the generic shell.
8
9
  *
9
- * Bounded: MAX_KEYPRESSES = 50_000 hard limit (reliability rule — every loop bounded).
10
+ * Public API (frozen — agents-terminal.test.ts is the acceptance gate):
11
+ * - runAgentsTui(initialState, io?) → Promise<TuiResult>
12
+ * - MAX_KEYPRESSES (re-exported from shared shell)
13
+ * - TuiIO (re-exported from shared shell)
14
+ * - TuiResult
10
15
  *
11
- * Returns a Promise resolving to { action: 'save'|'cancel', state } on any
12
- * terminal event that terminates the TUI.
16
+ * Bounded: MAX_KEYPRESSES = 50_000 hard limit (re-exported from src/cli/tui/terminal.ts).
13
17
  */
14
- import * as readline from 'readline';
15
18
  import { reduce } from './state.js';
16
19
  import { renderFrame, computeViewportHeight } from './render.js';
17
- // ---------------------------------------------------------------------------
18
- // Constants
19
- // ---------------------------------------------------------------------------
20
- /** Hard upper bound on keypress events — resolves with 'cancel' on exhaustion. */
21
- export const MAX_KEYPRESSES = 50_000;
22
- // ---------------------------------------------------------------------------
23
- // Terminal escape sequences
24
- // ---------------------------------------------------------------------------
25
- const ESC = '\x1b';
26
- const ENTER_ALT = `${ESC}[?1049h`;
27
- const LEAVE_ALT = `${ESC}[?1049l`;
28
- const HIDE_CURSOR = `${ESC}[?25l`;
29
- const SHOW_CURSOR = `${ESC}[?25h`;
30
- /** Move cursor to top-left without clearing (less flicker than full clear). */
31
- const HOME = `${ESC}[H`;
32
- /** Erase from cursor to end of line. */
33
- const ERASE_EOL = `${ESC}[K`;
34
- function normalizeKey(str, key) {
35
- if (key?.ctrl && key.name === 'c')
36
- return 'ctrl-c';
37
- const name = key?.name ?? '';
38
- switch (name) {
39
- case 'up': return 'up';
40
- case 'down': return 'down';
41
- case 'left': return 'left';
42
- case 'right': return 'right';
43
- case 'tab': return 'tab';
44
- case 'return': return 'enter';
45
- case 'escape': return 'escape';
46
- case 'space': return 'space';
47
- default:
48
- return str ?? name;
49
- }
50
- }
51
- // ---------------------------------------------------------------------------
52
- // Dims / viewport
53
- // ---------------------------------------------------------------------------
54
- function getDims(stdout) {
55
- return {
56
- rows: stdout.rows ?? 24,
57
- cols: stdout.columns ?? 80,
58
- };
59
- }
60
- // ---------------------------------------------------------------------------
61
- // Redraw
62
- // ---------------------------------------------------------------------------
63
- function redraw(state, stdout) {
64
- const dims = getDims(stdout);
65
- const lines = renderFrame(state, dims);
66
- let out = HOME;
67
- for (const line of lines) {
68
- out += line + ERASE_EOL + '\n';
69
- }
70
- stdout.write(out);
71
- }
20
+ import { runTui } from '../tui/terminal.js';
21
+ export { MAX_KEYPRESSES } from '../tui/terminal.js';
72
22
  // ---------------------------------------------------------------------------
73
23
  // runAgentsTui
74
24
  // ---------------------------------------------------------------------------
@@ -81,99 +31,25 @@ function redraw(state, stdout) {
81
31
  * @returns Promise resolving to { action, state } when the user saves or cancels.
82
32
  */
83
33
  export async function runAgentsTui(initialState, io) {
84
- // D-SEAM: default to process.stdin/stdout; callers (tests) may inject fakes.
85
- const stdin = (io?.stdin ?? process.stdin);
86
- const stdout = (io?.stdout ?? process.stdout);
87
- // ── Enable readline keypress events ─────────────────────────────────────
88
- // TuiIO.stdin is NodeJS.ReadableStream, which readline.emitKeypressEvents expects
89
- // directly. PassThrough (tests) and process.stdin (production) both satisfy it.
90
- readline.emitKeypressEvents(stdin);
91
- // ── Enter alt-screen, hide cursor ───────────────────────────────────────
92
- stdout.write(ENTER_ALT + HIDE_CURSOR);
93
- // ── Raw mode ─────────────────────────────────────────────────────────────
94
- if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
95
- stdin.setRawMode(true);
96
- }
97
- stdin.resume();
98
- return new Promise((resolve) => {
99
- let state = initialState;
100
- let cleaned = false;
101
- let keypressCount = 0;
102
- // Initial viewport size
103
- const dims = getDims(stdout);
104
- state = { ...state, viewportHeight: computeViewportHeight(dims.rows) };
105
- redraw(state, stdout);
106
- // ── Cleanup (idempotent) ───────────────────────────────────────────────
107
- function cleanup() {
108
- if (cleaned)
109
- return;
110
- cleaned = true;
111
- stdin.removeListener('keypress', onKeypress);
112
- process.removeListener('SIGINT', onSigint);
113
- process.removeListener('SIGTERM', onSigterm);
114
- stdout.removeListener('resize', onResize);
115
- if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
116
- try {
117
- stdin.setRawMode(false);
118
- }
119
- catch { /* ignore */ }
120
- }
121
- // Pause stdin to release the ref'd TTY handle — mirrors the stdin.resume()
122
- // at startup. Without this the resumed stdin keeps the event loop alive and
123
- // the CLI (which has no forced process.exit) hangs after the TUI resolves.
124
- stdin.pause();
125
- stdout.write(LEAVE_ALT + SHOW_CURSOR);
126
- }
127
- function settle(result) {
128
- cleanup();
129
- resolve(result);
130
- }
131
- // ── Resize handler ─────────────────────────────────────────────────────
132
- function onResize() {
133
- const d = getDims(stdout);
134
- state = { ...state, viewportHeight: computeViewportHeight(d.rows) };
135
- redraw(state, stdout);
136
- }
137
- // ── Keypress handler ───────────────────────────────────────────────────
138
- function onKeypress(str, key) {
139
- keypressCount++;
140
- if (keypressCount > MAX_KEYPRESSES) {
141
- // Hard safety bound — cancel on exhaustion (avoids unbounded event loop)
142
- settle({ action: 'cancel', state });
143
- return;
144
- }
145
- const normalized = normalizeKey(str, key);
146
- const { state: next, intent } = reduce(state, normalized);
147
- state = next;
148
- switch (intent) {
149
- case 'save':
150
- settle({ action: 'save', state });
151
- return;
152
- case 'cancel':
153
- settle({ action: 'cancel', state });
154
- return;
155
- case 'none':
156
- redraw(state, stdout);
157
- return;
158
- default: {
159
- const _ = intent;
160
- void _;
161
- redraw(state, stdout);
162
- }
163
- }
164
- }
165
- // ── Signal handlers ────────────────────────────────────────────────────
166
- function onSigint() {
167
- settle({ action: 'cancel', state });
168
- }
169
- function onSigterm() {
170
- settle({ action: 'cancel', state });
171
- }
172
- // Register all listeners
173
- stdin.on('keypress', onKeypress);
174
- process.on('SIGINT', onSigint);
175
- process.on('SIGTERM', onSigterm);
176
- stdout.on('resize', onResize);
34
+ // C='none' makes runTui return Promise<{ intent: Exclude<Intent,'none'>; state }>.
35
+ // Exclude<Intent,'none'> = 'save' | 'cancel', which matches TuiResult.action exactly —
36
+ // no casts needed, and adding a new Intent member is a compile error here
37
+ // (exhaustiveness enforced at the type level).
38
+ const result = await runTui({
39
+ initialState,
40
+ reduce,
41
+ renderFrame,
42
+ onResize: (state, dims) => ({
43
+ ...state,
44
+ viewportHeight: computeViewportHeight(dims.rows),
45
+ }),
46
+ signalAction: 'cancel',
47
+ continueIntent: 'none',
48
+ io,
177
49
  });
50
+ return {
51
+ action: result.intent,
52
+ state: result.state,
53
+ };
178
54
  }
179
55
  //# sourceMappingURL=terminal.js.map
@@ -563,7 +563,17 @@ export const agentsCommand = new Command('agents')
563
563
  const tuiState = await buildTuiState(agentNames, mapping, shippedDefaults, proxyEnabled, catalog, installDir);
564
564
  // Lazy-import terminal to avoid loading readline/tty in non-TTY paths
565
565
  const { runAgentsTui } = await import('../agents-view/terminal.js');
566
- const result = await runAgentsTui(tuiState);
566
+ // Wrap: runTui rejects on initial-render failure or handler throw.
567
+ // On rejection: log and bail — no partial write (avoids PF-014 process.exit).
568
+ let result;
569
+ try {
570
+ result = await runAgentsTui(tuiState);
571
+ }
572
+ catch (err) {
573
+ p.log.error(`Agent editor failed: ${err instanceof Error ? err.message : String(err)}`);
574
+ process.exitCode = 1;
575
+ return;
576
+ }
567
577
  if (result.action === 'cancel') {
568
578
  p.outro(color.dim('No changes made.'));
569
579
  return;