agileflow 3.0.2 → 3.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
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-02-14
11
+
12
+ ### Added
13
+ - Legal audit system, native Agent Teams integration, and startup performance improvements
14
+
10
15
  ## [3.0.2] - 2026-02-14
11
16
 
12
17
  ### Added
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  </p>
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/agileflow?color=brightgreen)](https://www.npmjs.com/package/agileflow)
6
- [![Commands](https://img.shields.io/badge/commands-93-blue)](docs/04-architecture/commands.md)
7
- [![Agents/Experts](https://img.shields.io/badge/agents%2Fexperts-45-orange)](docs/04-architecture/subagents.md)
6
+ [![Commands](https://img.shields.io/badge/commands-94-blue)](docs/04-architecture/commands.md)
7
+ [![Agents/Experts](https://img.shields.io/badge/agents%2Fexperts-55-orange)](docs/04-architecture/subagents.md)
8
8
  [![Skills](https://img.shields.io/badge/skills-dynamic-purple)](docs/04-architecture/skills.md)
9
9
 
10
10
  **AI-driven agile development for Claude Code, Cursor, Windsurf, OpenAI Codex CLI, and more.** Combining Scrum, Kanban, ADRs, and docs-as-code principles into one framework-agnostic system.
@@ -39,9 +39,9 @@ npx agileflow@latest update
39
39
  | IDE | Status | Config Location |
40
40
  |-----|--------|-----------------|
41
41
  | Claude Code | Supported | `.claude/commands/agileflow/` |
42
- | Cursor | Supported | `.cursor/rules/agileflow/` |
42
+ | Cursor | Supported | `.cursor/commands/agileflow/` |
43
43
  | Windsurf | Supported | `.windsurf/workflows/agileflow/` |
44
- | OpenAI Codex CLI | Supported | `.codex/skills/` |
44
+ | OpenAI Codex CLI | Supported | `.codex/skills/` and `~/.codex/prompts/` |
45
45
 
46
46
  ---
47
47
 
@@ -65,8 +65,8 @@ AgileFlow combines three proven methodologies:
65
65
 
66
66
  | Component | Count | Description |
67
67
  |-----------|-------|-------------|
68
- | [Commands](docs/04-architecture/commands.md) | 93 | Slash commands for agile workflows |
69
- | [Agents/Experts](docs/04-architecture/subagents.md) | 45 | Specialized agents with self-improving knowledge bases |
68
+ | [Commands](docs/04-architecture/commands.md) | 94 | Slash commands for agile workflows |
69
+ | [Agents/Experts](docs/04-architecture/subagents.md) | 55 | Specialized agents with self-improving knowledge bases |
70
70
  | [Skills](docs/04-architecture/skills.md) | Dynamic | Generated on-demand with `/agileflow:skill:create` |
71
71
 
72
72
  ---
@@ -76,8 +76,8 @@ AgileFlow combines three proven methodologies:
76
76
  Full documentation lives in [`docs/04-architecture/`](docs/04-architecture/):
77
77
 
78
78
  ### Reference
79
- - [Commands](docs/04-architecture/commands.md) - All 93 slash commands
80
- - [Agents/Experts](docs/04-architecture/subagents.md) - 45 specialized agents with self-improving knowledge
79
+ - [Commands](docs/04-architecture/commands.md) - All 94 slash commands
80
+ - [Agents/Experts](docs/04-architecture/subagents.md) - 55 specialized agents with self-improving knowledge
81
81
  - [Skills](docs/04-architecture/skills.md) - Dynamic skill generator with MCP integration
82
82
 
83
83
  ### Architecture
package/lib/feedback.js CHANGED
@@ -34,7 +34,14 @@
34
34
  */
35
35
 
36
36
  const { c, BRAND_HEX } = require('./colors');
37
- const chalk = require('chalk');
37
+ const { lazyRequire } = require('./lazy-require');
38
+
39
+ // Lazy-load chalk: feedback.js is imported by scripts that run as hooks in
40
+ // user projects (.agileflow/scripts/). If chalk isn't resolvable from the
41
+ // user's node_modules the eager require() would crash every hook that
42
+ // imports feedback. Deferring the require() to the single call-site that
43
+ // actually needs chalk (brand()) avoids the crash entirely.
44
+ const getChalk = lazyRequire('chalk');
38
45
 
39
46
  // Symbols for consistent output
40
47
  const SYMBOLS = {
@@ -182,7 +189,7 @@ class Feedback {
182
189
  brand(message) {
183
190
  if (this.quiet) return this;
184
191
  const prefix = this._indent();
185
- console.log(`${prefix}${chalk.hex(BRAND_HEX)(message)}`);
192
+ console.log(`${prefix}${getChalk().hex(BRAND_HEX)(message)}`);
186
193
  return this;
187
194
  }
188
195
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * lazy-require.js - Reusable Lazy-Loading Utility
3
+ *
4
+ * AgileFlow scripts are copied to user projects (.agileflow/scripts/) and run
5
+ * as hooks. When these scripts eagerly require() npm dependencies at module
6
+ * load time, they crash if the dependency isn't resolvable from the user's
7
+ * project directory. This utility standardizes the lazy-loading pattern used
8
+ * ad-hoc in yaml-utils.js and dashboard-server.js.
9
+ *
10
+ * Usage:
11
+ * const { lazyRequire } = require('./lazy-require');
12
+ *
13
+ * // Returns a getter function; require() is deferred until first call
14
+ * const getChalk = lazyRequire('chalk');
15
+ *
16
+ * // With fallback resolution paths
17
+ * const getYaml = lazyRequire('js-yaml',
18
+ * path.join(__dirname, '..', 'node_modules', 'js-yaml')
19
+ * );
20
+ *
21
+ * // Later, when actually needed:
22
+ * const chalk = getChalk();
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ /**
28
+ * Create a lazy-loading getter for an npm module.
29
+ *
30
+ * The returned function defers require() until first invocation, then caches
31
+ * the result. Multiple resolution paths are tried in order, so the module can
32
+ * be found from the user's node_modules, AgileFlow's own node_modules, or any
33
+ * other location.
34
+ *
35
+ * @param {string} name - Primary module name (passed to require())
36
+ * @param {...string} fallbackPaths - Additional paths to try if primary fails
37
+ * @returns {function(): any} Getter that returns the loaded module
38
+ */
39
+ function lazyRequire(name, ...fallbackPaths) {
40
+ let cached = null;
41
+ return () => {
42
+ if (cached) return cached;
43
+ const paths = [name, ...fallbackPaths];
44
+ for (const p of paths) {
45
+ try {
46
+ cached = require(p);
47
+ return cached;
48
+ } catch (_e) {
49
+ // Continue to next path
50
+ }
51
+ }
52
+ throw new Error(
53
+ `${name} not found. Run: npm install ${name}\n` +
54
+ 'Or reinstall AgileFlow: npx agileflow setup --force'
55
+ );
56
+ };
57
+ }
58
+
59
+ module.exports = { lazyRequire };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",
@@ -18,6 +18,7 @@
18
18
  * --enable=<features> Enable specific features
19
19
  * --disable=<features> Disable specific features
20
20
  * --archival-days=<N> Set archival threshold
21
+ * --startup-mode=<MODE> Set default startup mode (atomic)
21
22
  * --migrate Fix old formats without changing features
22
23
  * --validate Check for issues
23
24
  * --detect Show current status
@@ -42,6 +43,7 @@ const {
42
43
  listStatuslineComponents,
43
44
  migrateSettings,
44
45
  upgradeFeatures,
46
+ enableStartupMode,
45
47
  } = require('./lib/configure-features');
46
48
  const { listScripts, showVersionInfo, repairScripts } = require('./lib/configure-repair');
47
49
  const { feedback } = require('../lib/feedback');
@@ -144,6 +146,7 @@ ${c.cyan}Statusline Components:${c.reset}
144
146
 
145
147
  ${c.cyan}Settings:${c.reset}
146
148
  --archival-days=N Set archival threshold (default: 30)
149
+ --startup-mode=MODE Set default startup mode (skip-permissions, accept-edits, normal, no-claude)
147
150
 
148
151
  ${c.cyan}Maintenance:${c.reset}
149
152
  --migrate Fix old/invalid formats
@@ -201,6 +204,7 @@ function main() {
201
204
  let repairFeature = null;
202
205
  let showVersion = false;
203
206
  let listScriptsMode = false;
207
+ let startupMode = null;
204
208
 
205
209
  args.forEach(arg => {
206
210
  if (arg.startsWith('--profile=')) profile = arg.split('=')[1];
@@ -237,6 +241,8 @@ function main() {
237
241
  repairFeature = arg.split('=')[1].trim().toLowerCase();
238
242
  } else if (arg === '--version' || arg === '-v') showVersion = true;
239
243
  else if (arg === '--list-scripts' || arg === '--scripts') listScriptsMode = true;
244
+ else if (arg.startsWith('--startup-mode='))
245
+ startupMode = arg.split('=')[1].trim().toLowerCase();
240
246
  });
241
247
 
242
248
  // Help mode
@@ -289,6 +295,12 @@ function main() {
289
295
  spinner.succeed('Configuration detected');
290
296
  const { hasIssues, hasOutdated } = printStatus(status);
291
297
 
298
+ // Startup mode (atomic - sets BOTH metadata AND settings.json)
299
+ if (startupMode) {
300
+ enableStartupMode(startupMode, VERSION);
301
+ return;
302
+ }
303
+
292
304
  // Detect only mode
293
305
  if (detect && !migrate && !upgrade && !profile && enable.length === 0 && disable.length === 0) {
294
306
  return;
@@ -30,6 +30,20 @@ const { readJSONCached, readFileCached } = require('../lib/file-cache');
30
30
  // Session manager path (relative to script location)
31
31
  const SESSION_MANAGER_PATH = path.join(__dirname, 'session-manager.js');
32
32
 
33
+ // PERFORMANCE OPTIMIZATION: Lazy-loaded session-manager module
34
+ // Importing directly avoids ~50-150ms subprocess overhead per call.
35
+ let _sessionManager;
36
+ function getSessionManager() {
37
+ if (_sessionManager === undefined) {
38
+ try {
39
+ _sessionManager = require('./session-manager.js');
40
+ } catch (e) {
41
+ _sessionManager = null;
42
+ }
43
+ }
44
+ return _sessionManager;
45
+ }
46
+
33
47
  // Hook metrics module (kept at top level - needed early for timer)
34
48
  let hookMetrics;
35
49
  try {
@@ -43,6 +57,12 @@ try {
43
57
  * Uses file-cache module for automatic caching with 15s TTL.
44
58
  * Files are cached across script invocations within TTL window.
45
59
  * Estimated savings: 60-120ms on cache hits
60
+ *
61
+ * Additional optimizations in this file (US-0356):
62
+ * - Git batching: 3 subprocess calls → 1 (~20-40ms savings)
63
+ * - Session-manager inline: subprocess → direct require() (~50-150ms savings)
64
+ * - Tmux cache: subprocess → session-state lookup (~10-20ms after first run)
65
+ * Total estimated savings: ~130-260ms
46
66
  */
47
67
  function loadProjectFiles(rootDir) {
48
68
  const paths = {
@@ -109,32 +129,57 @@ function detectPlatform() {
109
129
 
110
130
  /**
111
131
  * Check if tmux is installed
132
+ * PERFORMANCE OPTIMIZATION: Caches result in session-state.json (~10-20ms savings on subsequent runs)
112
133
  * Returns object with availability info and platform-specific install suggestion
113
134
  */
114
- function checkTmuxAvailability() {
135
+ function checkTmuxAvailability(cache) {
136
+ // Check session state cache first (tmux availability doesn't change within a session)
137
+ if (cache?.sessionState?.tmux_available !== undefined) {
138
+ if (cache.sessionState.tmux_available) return { available: true };
139
+ return { available: false, platform: detectPlatform(), noSudoCmd: 'conda install -c conda-forge tmux' };
140
+ }
141
+
142
+ // Actually check (first run or no cache)
115
143
  const result = executeCommandSync('which', ['tmux'], { fallback: null });
116
- if (result.data !== null) {
117
- return { available: true };
144
+ const available = result.data !== null;
145
+
146
+ // Cache in session state for next invocation
147
+ try {
148
+ const rootDir = getProjectRoot();
149
+ const sessionStatePath = getSessionStatePath(rootDir);
150
+ if (fs.existsSync(sessionStatePath)) {
151
+ const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
152
+ state.tmux_available = available;
153
+ fs.writeFileSync(sessionStatePath, JSON.stringify(state, null, 2) + '\n');
154
+ }
155
+ } catch (e) {
156
+ // Cache write failed, non-critical
118
157
  }
119
- const platform = detectPlatform();
120
- return {
121
- available: false,
122
- platform,
123
- noSudoCmd: 'conda install -c conda-forge tmux',
124
- };
158
+
159
+ if (available) return { available: true };
160
+ return { available: false, platform: detectPlatform(), noSudoCmd: 'conda install -c conda-forge tmux' };
125
161
  }
126
162
 
127
163
  /**
128
164
  * PERFORMANCE OPTIMIZATION: Batch git commands into single call
129
- * Reduces subprocess overhead from 3 calls to 1.
130
- * Estimated savings: 20-40ms
165
+ * Uses `git log -1 --format=%D%n%h%n%s` to get branch, short hash, and subject
166
+ * in a single subprocess instead of 3 separate calls.
167
+ * Savings: ~20-40ms (eliminates 2 subprocess spawns)
131
168
  */
132
169
  function getGitInfo(rootDir) {
133
- const opts = { cwd: rootDir, timeout: 5000, fallback: 'unknown' };
134
- const branch = git(['branch', '--show-current'], opts).data;
135
- const commit = git(['rev-parse', '--short', 'HEAD'], opts).data;
136
- const lastCommit = git(['log', '-1', '--format=%s'], { ...opts, fallback: '' }).data;
137
- return { branch, commit, lastCommit };
170
+ const opts = { cwd: rootDir, timeout: 5000, fallback: '' };
171
+ const result = git(['log', '-1', '--format=%D%n%h%n%s'], opts);
172
+ if (result.data) {
173
+ const lines = result.data.split('\n');
174
+ // %D gives decorations like "HEAD -> main, origin/main, tag: v3.0.0"
175
+ const branchMatch = (lines[0] || '').match(/HEAD -> ([^,\s]+)/);
176
+ return {
177
+ branch: branchMatch ? branchMatch[1] : 'detached',
178
+ commit: (lines[1] || 'unknown').trim(),
179
+ lastCommit: lines.slice(2).join('\n').trim(),
180
+ };
181
+ }
182
+ return { branch: 'unknown', commit: 'unknown', lastCommit: '' };
138
183
  }
139
184
 
140
185
  function getProjectInfo(rootDir, cache = null) {
@@ -403,18 +448,36 @@ function checkParallelSessions(rootDir) {
403
448
  };
404
449
 
405
450
  try {
406
- // Check if session manager exists
451
+ // PERFORMANCE OPTIMIZATION: Import session-manager directly instead of subprocess
452
+ // Saves ~50-150ms by avoiding Node subprocess spawn overhead
453
+ const sm = getSessionManager();
454
+ if (sm && sm.fullStatus) {
455
+ result.available = true;
456
+ const data = sm.fullStatus();
457
+ result.registered = data.registered;
458
+ result.currentId = data.id;
459
+ result.otherActive = data.otherActive || 0;
460
+ result.cleaned = data.cleaned || 0;
461
+ result.cleanedSessions = data.cleanedSessions || [];
462
+
463
+ if (data.current) {
464
+ result.isMain = data.current.is_main === true;
465
+ result.nickname = data.current.nickname;
466
+ result.branch = data.current.branch;
467
+ result.sessionPath = data.current.path;
468
+ }
469
+ return result;
470
+ }
471
+
472
+ // Fallback: check if session manager script exists for subprocess call
407
473
  const managerPath = path.join(getAgileflowDir(rootDir), 'scripts', 'session-manager.js');
408
474
  if (!fs.existsSync(managerPath) && !fs.existsSync(SESSION_MANAGER_PATH)) {
409
475
  return result;
410
476
  }
411
477
 
412
478
  result.available = true;
413
-
414
- // Try to use combined full-status command (saves ~200ms vs 3 separate calls)
415
479
  const scriptPath = fs.existsSync(managerPath) ? managerPath : SESSION_MANAGER_PATH;
416
480
 
417
- // PERFORMANCE: Single subprocess call instead of 3 (register + count + status)
418
481
  const fullStatusResult = executeCommandSync('node', [scriptPath, 'full-status'], {
419
482
  cwd: rootDir,
420
483
  fallback: null,
@@ -435,52 +498,7 @@ function checkParallelSessions(rootDir) {
435
498
  result.branch = data.current.branch;
436
499
  result.sessionPath = data.current.path;
437
500
  }
438
- } catch (e) {
439
- // JSON parse failed, fall through to individual calls
440
- fullStatusResult.data = null;
441
- }
442
- }
443
-
444
- if (!fullStatusResult.data) {
445
- // Fall back to individual calls if full-status not available (older version)
446
- const registerResult = executeCommandSync('node', [scriptPath, 'register'], {
447
- cwd: rootDir,
448
- fallback: null,
449
- });
450
- if (registerResult.data) {
451
- try {
452
- const registerData = JSON.parse(registerResult.data);
453
- result.registered = true;
454
- result.currentId = registerData.id;
455
- } catch (e) {}
456
- }
457
-
458
- const countResult = executeCommandSync('node', [scriptPath, 'count'], {
459
- cwd: rootDir,
460
- fallback: null,
461
- });
462
- if (countResult.data) {
463
- try {
464
- const countData = JSON.parse(countResult.data);
465
- result.otherActive = countData.count || 0;
466
- } catch (e) {}
467
- }
468
-
469
- const statusCmdResult = executeCommandSync('node', [scriptPath, 'status'], {
470
- cwd: rootDir,
471
- fallback: null,
472
- });
473
- if (statusCmdResult.data) {
474
- try {
475
- const statusData = JSON.parse(statusCmdResult.data);
476
- if (statusData.current) {
477
- result.isMain = statusData.current.is_main === true;
478
- result.nickname = statusData.current.nickname;
479
- result.branch = statusData.current.branch;
480
- result.sessionPath = statusData.current.path;
481
- }
482
- } catch (e) {}
483
- }
501
+ } catch (e) {}
484
502
  }
485
503
  } catch (e) {
486
504
  // Session system not available
@@ -1752,7 +1770,7 @@ async function main() {
1752
1770
  let tmuxCheck = { available: true };
1753
1771
  const tmuxAutoSpawnEnabled = cache?.metadata?.features?.tmuxAutoSpawn?.enabled !== false;
1754
1772
  if (tmuxAutoSpawnEnabled) {
1755
- tmuxCheck = checkTmuxAvailability();
1773
+ tmuxCheck = checkTmuxAvailability(cache);
1756
1774
  }
1757
1775
 
1758
1776
  // Show session banner FIRST if in a non-main session
@@ -1897,14 +1915,12 @@ async function main() {
1897
1915
 
1898
1916
  // === SESSION HEALTH WARNINGS ===
1899
1917
  // Check for forgotten sessions with uncommitted changes, stale sessions, orphaned entries
1918
+ // PERFORMANCE OPTIMIZATION: Direct function call instead of subprocess (~50-100ms savings)
1900
1919
  try {
1901
- const healthResult = executeCommandSync('node', [SESSION_MANAGER_PATH, 'health'], {
1902
- timeout: 10000,
1903
- fallback: null,
1904
- });
1920
+ const sm = getSessionManager();
1921
+ const health = sm ? sm.getSessionsHealth({ staleDays: 7 }) : null;
1905
1922
 
1906
- if (healthResult.data) {
1907
- const health = JSON.parse(healthResult.data);
1923
+ if (health) {
1908
1924
  const hasIssues =
1909
1925
  health.uncommitted.length > 0 ||
1910
1926
  health.stale.length > 0 ||
@@ -360,7 +360,7 @@ fi
360
360
  # Silently remove sessions where all panes have exited (dead/empty shells).
361
361
  # This prevents accumulation of orphan sessions over time.
362
362
  SESSION_BASE="claude-${DIR_NAME}"
363
- for sid in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${SESSION_BASE}\(\$\|-[0-9]*\$\)"); do
363
+ for sid in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -E "^${SESSION_BASE}($|-[0-9]+$)"); do
364
364
  # Count alive panes (pane_dead=0 means alive)
365
365
  ALIVE=$(tmux list-panes -t "$sid" -F '#{pane_dead}' 2>/dev/null | grep -c '^0$' || true)
366
366
  if [ "$ALIVE" = "0" ]; then
@@ -368,6 +368,36 @@ for sid in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${SESS
368
368
  fi
369
369
  done
370
370
 
371
+ # ── Consolidate duplicate sessions ───────────────────────────────────────
372
+ # Kill numbered duplicates (e.g. claude-Acuide-2, -3) that were created by
373
+ # a previous bug. If the base session exists, duplicates are unnecessary.
374
+ # If only numbered sessions remain, promote the lowest to the base name.
375
+ if [ "$FORCE_NEW" = false ]; then
376
+ HAS_BASE=false
377
+ NUMBERED=()
378
+ if tmux has-session -t "$SESSION_BASE" 2>/dev/null; then
379
+ HAS_BASE=true
380
+ fi
381
+ while IFS= read -r sid; do
382
+ [ -n "$sid" ] && NUMBERED+=("$sid")
383
+ done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -E "^${SESSION_BASE}-[0-9]+$" | sort -t- -k3 -n)
384
+
385
+ if [ "$HAS_BASE" = true ] && [ "${#NUMBERED[@]}" -gt 0 ]; then
386
+ # Base exists — kill all numbered duplicates
387
+ for sid in "${NUMBERED[@]}"; do
388
+ tmux kill-session -t "$sid" 2>/dev/null || true
389
+ done
390
+ elif [ "$HAS_BASE" = false ] && [ "${#NUMBERED[@]}" -gt 0 ]; then
391
+ # No base — promote lowest numbered session to base name
392
+ PROMOTE="${NUMBERED[0]}"
393
+ tmux rename-session -t "$PROMOTE" "$SESSION_BASE" 2>/dev/null || true
394
+ # Kill remaining duplicates
395
+ for sid in "${NUMBERED[@]:1}"; do
396
+ tmux kill-session -t "$sid" 2>/dev/null || true
397
+ done
398
+ fi
399
+ fi
400
+
371
401
  # ── Auto-reattach to detached session ──────────────────────────────────────
372
402
  # When user does Alt+Q (detach) and then runs `af` again, reattach to the
373
403
  # existing session instead of creating a new one. This preserves tmux windows,
@@ -377,7 +407,7 @@ if [ "$FORCE_NEW" = false ]; then
377
407
  DETACHED=()
378
408
  while IFS= read -r sid; do
379
409
  [ -n "$sid" ] && DETACHED+=("$sid")
380
- done < <(tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | awk '$2 == "0" {print $1}' | grep "^${SESSION_BASE}\(\$\|-[0-9]*\$\)")
410
+ done < <(tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | awk '$2 == "0" {print $1}' | grep -E "^${SESSION_BASE}($|-[0-9]+$)")
381
411
 
382
412
  if [ "${#DETACHED[@]}" -eq 1 ]; then
383
413
  # Single detached session — just reattach
@@ -416,7 +446,7 @@ if [ "$FORCE_NEW" = false ]; then
416
446
  EXISTING=()
417
447
  while IFS= read -r sid; do
418
448
  [ -n "$sid" ] && EXISTING+=("$sid")
419
- done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${SESSION_BASE}\(\$\|-[0-9]*\$\)")
449
+ done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -E "^${SESSION_BASE}($|-[0-9]+$)")
420
450
 
421
451
  if [ "${#EXISTING[@]}" -gt 0 ]; then
422
452
  # Prefer the base session, otherwise pick the first one
@@ -1215,6 +1215,119 @@ function upgradeFeatures(status, version) {
1215
1215
  return upgraded > 0;
1216
1216
  }
1217
1217
 
1218
+ // ============================================================================
1219
+ // STARTUP MODE (atomic command)
1220
+ // ============================================================================
1221
+
1222
+ /**
1223
+ * Valid startup modes and their mappings
1224
+ */
1225
+ const STARTUP_MODES = {
1226
+ 'skip-permissions': {
1227
+ flags: '--dangerously-skip-permissions',
1228
+ defaultMode: 'bypassPermissions',
1229
+ description: 'Skip all permission prompts (trusted mode)',
1230
+ },
1231
+ 'accept-edits': {
1232
+ flags: '--permission-mode acceptEdits',
1233
+ defaultMode: 'acceptEdits',
1234
+ description: 'Auto-accept file edits, prompt for other actions',
1235
+ },
1236
+ normal: {
1237
+ flags: null,
1238
+ defaultMode: null,
1239
+ description: 'Standard Claude with permission prompts',
1240
+ },
1241
+ 'no-claude': {
1242
+ flags: null,
1243
+ defaultMode: null,
1244
+ description: 'Create worktree only, start Claude manually',
1245
+ },
1246
+ };
1247
+
1248
+ /**
1249
+ * Set startup mode atomically - updates BOTH metadata AND .claude/settings.json
1250
+ * This replaces the fragile two-step process of updating metadata + running --enable=claudeflags
1251
+ *
1252
+ * @param {string} mode - One of: skip-permissions, accept-edits, normal, no-claude
1253
+ * @param {string} version - Current version string
1254
+ * @returns {boolean} Success
1255
+ */
1256
+ function enableStartupMode(mode, version) {
1257
+ const modeConfig = STARTUP_MODES[mode];
1258
+ if (!modeConfig) {
1259
+ error(`Unknown startup mode: ${mode}`);
1260
+ log(` Valid modes: ${Object.keys(STARTUP_MODES).join(', ')}`, c.dim);
1261
+ return false;
1262
+ }
1263
+
1264
+ ensureDir('.claude');
1265
+ const settings = readJSON('.claude/settings.json') || {};
1266
+ settings.permissions = settings.permissions || { allow: [], deny: [], ask: [] };
1267
+
1268
+ if (mode === 'normal' || mode === 'no-claude') {
1269
+ // Remove defaultMode from settings
1270
+ if (settings.permissions.defaultMode) {
1271
+ delete settings.permissions.defaultMode;
1272
+ }
1273
+ writeJSON('.claude/settings.json', settings);
1274
+
1275
+ // Disable claudeflags + set defaultStartupMode in metadata (single write)
1276
+ updateMetadata(
1277
+ {
1278
+ features: {
1279
+ claudeFlags: {
1280
+ enabled: false,
1281
+ defaultFlags: '',
1282
+ version,
1283
+ at: new Date().toISOString(),
1284
+ },
1285
+ },
1286
+ },
1287
+ version
1288
+ );
1289
+ } else {
1290
+ // Set defaultMode in settings.json
1291
+ settings.permissions.defaultMode = modeConfig.defaultMode;
1292
+ writeJSON('.claude/settings.json', settings);
1293
+
1294
+ // Enable claudeflags + set defaultStartupMode in metadata (single write)
1295
+ updateMetadata(
1296
+ {
1297
+ features: {
1298
+ claudeFlags: {
1299
+ enabled: true,
1300
+ defaultFlags: modeConfig.flags,
1301
+ version,
1302
+ at: new Date().toISOString(),
1303
+ },
1304
+ },
1305
+ },
1306
+ version
1307
+ );
1308
+ }
1309
+
1310
+ // Set defaultStartupMode in metadata (updateMetadata already created file if missing)
1311
+ const metaPath = 'docs/00-meta/agileflow-metadata.json';
1312
+ const meta = readJSON(metaPath) || {};
1313
+ meta.defaultStartupMode = mode;
1314
+ meta.updated = new Date().toISOString();
1315
+ writeJSON(metaPath, meta);
1316
+
1317
+ success(`Default startup mode set to: ${mode}`);
1318
+ if (modeConfig.defaultMode) {
1319
+ info(`Set permissions.defaultMode = "${modeConfig.defaultMode}" in .claude/settings.json`);
1320
+ } else {
1321
+ info('Removed permissions.defaultMode from .claude/settings.json');
1322
+ }
1323
+ info(`Metadata: defaultStartupMode = "${mode}"`);
1324
+ if (mode !== 'normal') {
1325
+ info('Restart Claude Code for the new mode to take effect');
1326
+ }
1327
+
1328
+ return true;
1329
+ }
1330
+
1218
1331
  // ============================================================================
1219
1332
  // SHELL ALIASES
1220
1333
  // ============================================================================
@@ -1473,6 +1586,9 @@ module.exports = {
1473
1586
  // Helpers
1474
1587
  scriptExists,
1475
1588
  getScriptPath,
1589
+ // Startup mode
1590
+ enableStartupMode,
1591
+ STARTUP_MODES,
1476
1592
  // Shell aliases
1477
1593
  enableShellAliases,
1478
1594
  disableShellAliases,