agileflow 2.99.8 → 3.0.1

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/lib/cache-provider.js +155 -0
  4. package/lib/codebase-indexer.js +1 -1
  5. package/lib/content-sanitizer.js +1 -0
  6. package/lib/dashboard-protocol.js +25 -0
  7. package/lib/dashboard-server.js +282 -150
  8. package/lib/errors.js +18 -0
  9. package/lib/file-cache.js +1 -1
  10. package/lib/flag-detection.js +11 -20
  11. package/lib/git-operations.js +15 -33
  12. package/lib/merge-operations.js +40 -34
  13. package/lib/process-executor.js +199 -0
  14. package/lib/registry-cache.js +13 -47
  15. package/lib/skill-loader.js +206 -0
  16. package/lib/smart-json-file.js +2 -4
  17. package/package.json +1 -1
  18. package/scripts/agileflow-configure.js +13 -12
  19. package/scripts/agileflow-statusline.sh +30 -0
  20. package/scripts/agileflow-welcome.js +181 -212
  21. package/scripts/archive-completed-stories.sh +3 -0
  22. package/scripts/auto-self-improve.js +3 -3
  23. package/scripts/ci-summary.js +294 -0
  24. package/scripts/claude-smart.sh +85 -0
  25. package/scripts/claude-tmux.sh +272 -161
  26. package/scripts/damage-control-multi-agent.js +227 -0
  27. package/scripts/lib/bus-utils.js +471 -0
  28. package/scripts/lib/configure-detect.js +87 -10
  29. package/scripts/lib/configure-features.js +110 -4
  30. package/scripts/lib/configure-repair.js +5 -6
  31. package/scripts/lib/configure-utils.js +2 -3
  32. package/scripts/lib/context-formatter.js +87 -8
  33. package/scripts/lib/damage-control-utils.js +37 -3
  34. package/scripts/lib/file-lock.js +392 -0
  35. package/scripts/lib/ideation-index.js +2 -5
  36. package/scripts/lib/lifecycle-detector.js +123 -0
  37. package/scripts/lib/process-cleanup.js +55 -81
  38. package/scripts/lib/scale-detector.js +357 -0
  39. package/scripts/lib/signal-detectors.js +779 -0
  40. package/scripts/lib/story-state-machine.js +1 -1
  41. package/scripts/lib/sync-ideation-status.js +2 -3
  42. package/scripts/lib/task-registry.js +7 -1
  43. package/scripts/lib/team-events.js +357 -0
  44. package/scripts/messaging-bridge.js +79 -36
  45. package/scripts/migrate-ideation-index.js +37 -14
  46. package/scripts/obtain-context.js +37 -19
  47. package/scripts/precompact-context.sh +3 -0
  48. package/scripts/ralph-loop.js +3 -4
  49. package/scripts/smart-detect.js +390 -0
  50. package/scripts/team-manager.js +174 -30
  51. package/src/core/commands/audit.md +13 -11
  52. package/src/core/commands/babysit.md +162 -115
  53. package/src/core/commands/changelog.md +21 -4
  54. package/src/core/commands/configure.md +141 -21
  55. package/src/core/commands/debt.md +12 -2
  56. package/src/core/commands/feedback.md +7 -6
  57. package/src/core/commands/ideate/history.md +1 -1
  58. package/src/core/commands/ideate/new.md +5 -5
  59. package/src/core/commands/logic/audit.md +2 -2
  60. package/src/core/commands/pr.md +7 -6
  61. package/src/core/commands/research/analyze.md +28 -20
  62. package/src/core/commands/research/ask.md +43 -0
  63. package/src/core/commands/research/import.md +29 -21
  64. package/src/core/commands/research/list.md +8 -7
  65. package/src/core/commands/research/synthesize.md +356 -20
  66. package/src/core/commands/research/view.md +8 -5
  67. package/src/core/commands/review.md +24 -6
  68. package/src/core/commands/skill/create.md +34 -0
  69. package/tools/cli/lib/docs-setup.js +4 -0
@@ -6,8 +6,51 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const crypto = require('crypto');
9
10
  const { execFileSync } = require('child_process');
10
11
  const { c, log, header, readJSON } = require('./configure-utils');
12
+ const { tryOptional } = require('../../lib/errors');
13
+ const { FEATURES } = require('./configure-features');
14
+
15
+ // ============================================================================
16
+ // CONTENT HASH HELPERS
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Hash a file's content using SHA-256 (first 16 hex chars)
21
+ * @param {string} filePath - Path to the file
22
+ * @returns {string|null} 16-char hex hash, or null if file can't be read
23
+ */
24
+ function hashFile(filePath) {
25
+ try {
26
+ const content = fs.readFileSync(filePath, 'utf8');
27
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Find the directory containing package source scripts.
35
+ * Checks require.resolve first, then common fallback locations.
36
+ * @returns {string|null} Path to the scripts directory, or null
37
+ */
38
+ function findPackageScriptDir() {
39
+ try {
40
+ const pkgPath = require.resolve('agileflow/package.json');
41
+ return path.join(path.dirname(pkgPath), 'scripts');
42
+ } catch {
43
+ // Fallback: check common locations
44
+ const candidates = [
45
+ path.join(process.cwd(), 'node_modules', 'agileflow', 'scripts'),
46
+ path.join(process.cwd(), 'packages', 'cli', 'scripts'), // monorepo dev
47
+ ];
48
+ for (const dir of candidates) {
49
+ if (fs.existsSync(dir)) return dir;
50
+ }
51
+ return null;
52
+ }
53
+ }
11
54
 
12
55
  // ============================================================================
13
56
  // DETECTION
@@ -64,12 +107,10 @@ function detectConfig(version) {
64
107
  // Git detection
65
108
  if (fs.existsSync('.git')) {
66
109
  status.git.initialized = true;
67
- try {
68
- status.git.remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
69
- encoding: 'utf8',
70
- stdio: ['pipe', 'pipe', 'pipe'],
71
- }).trim();
72
- } catch {}
110
+ status.git.remote = tryOptional(() => execFileSync('git', ['remote', 'get-url', 'origin'], {
111
+ encoding: 'utf8',
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ }).trim(), 'git remote') ?? null;
73
114
  }
74
115
 
75
116
  // Settings file detection
@@ -257,17 +298,51 @@ function detectMetadata(status, version) {
257
298
  status.features.tmuxautospawn.enabled = true; // Default enabled
258
299
  }
259
300
 
260
- // Read feature versions and check if outdated
301
+ // Read feature versions and check if outdated (content-based)
261
302
  if (meta.features) {
262
303
  const featureKeyMap = { askUserQuestion: 'askuserquestion', tmuxAutoSpawn: 'tmuxautospawn' };
304
+ const packageScriptDir = findPackageScriptDir();
305
+
263
306
  Object.entries(meta.features).forEach(([feature, data]) => {
264
307
  const statusKey = featureKeyMap[feature] || feature.toLowerCase();
265
308
  if (status.features[statusKey] && data.version) {
266
309
  status.features[statusKey].version = data.version;
267
- if (data.version !== version && status.features[statusKey].enabled) {
268
- status.features[statusKey].outdated = true;
269
- status.hasOutdated = true;
310
+
311
+ if (!status.features[statusKey].enabled) return;
312
+
313
+ // Content-based outdated detection
314
+ const featureConfig = FEATURES[statusKey];
315
+ const scriptsToCheck = featureConfig?.scripts
316
+ || (featureConfig?.script ? [featureConfig.script] : []);
317
+
318
+ if (scriptsToCheck.length > 0 && packageScriptDir) {
319
+ // Compare installed scripts against package source
320
+ let isOutdated = false;
321
+ for (const scriptName of scriptsToCheck) {
322
+ const packageScript = path.join(packageScriptDir, scriptName);
323
+ const installedScript = path.join(
324
+ process.cwd(), '.agileflow', 'scripts', scriptName
325
+ );
326
+ const packageHash = hashFile(packageScript);
327
+ const installedHash = hashFile(installedScript);
328
+
329
+ if (packageHash && installedHash && packageHash !== installedHash) {
330
+ isOutdated = true;
331
+ break;
332
+ }
333
+ }
334
+ if (isOutdated) {
335
+ status.features[statusKey].outdated = true;
336
+ status.hasOutdated = true;
337
+ }
338
+ } else if (featureConfig?.metadataOnly) {
339
+ // Metadata-only features: use version comparison (no scripts to hash)
340
+ if (data.version !== version) {
341
+ status.features[statusKey].outdated = true;
342
+ status.hasOutdated = true;
343
+ }
270
344
  }
345
+ // If no package source found or no scripts, don't mark outdated (fail open)
271
346
  }
272
347
  });
273
348
  }
@@ -403,4 +478,6 @@ module.exports = {
403
478
  detectPreToolUseHooks,
404
479
  detectStatusLine,
405
480
  detectMetadata,
481
+ hashFile,
482
+ findPackageScriptDir,
406
483
  };
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const crypto = require('crypto');
9
10
  const os = require('os');
10
11
  const {
11
12
  c,
@@ -53,8 +54,12 @@ const FEATURES = {
53
54
  description: 'Auto-kill duplicate Claude processes in same directory to prevent freezing',
54
55
  },
55
56
  claudeflags: {
56
- metadataOnly: true,
57
- description: 'Default flags for Claude CLI (e.g., --dangerously-skip-permissions)',
57
+ metadataOnly: false,
58
+ description: 'Default flags for Claude CLI (sets permissions.defaultMode in .claude/settings.json)',
59
+ },
60
+ agentteams: {
61
+ metadataOnly: false,
62
+ description: 'Enable Claude Code native Agent Teams (sets env var in .claude/settings.json)',
58
63
  },
59
64
  };
60
65
 
@@ -148,6 +153,20 @@ const SCRIPTS_DIR = path.join(process.cwd(), '.agileflow', 'scripts');
148
153
  const scriptExists = scriptName => fs.existsSync(path.join(SCRIPTS_DIR, scriptName));
149
154
  const getScriptPath = scriptName => `.agileflow/scripts/${scriptName}`;
150
155
 
156
+ /**
157
+ * Hash a file's content using SHA-256 (first 16 hex chars)
158
+ * @param {string} filePath - Path to the file
159
+ * @returns {string|null} 16-char hex hash, or null if file can't be read
160
+ */
161
+ function hashFile(filePath) {
162
+ try {
163
+ const content = fs.readFileSync(filePath, 'utf8');
164
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
151
170
  // ============================================================================
152
171
  // METADATA MANAGEMENT
153
172
  // ============================================================================
@@ -304,8 +323,24 @@ function enableFeature(feature, options = {}, version) {
304
323
  }
305
324
 
306
325
  // Handle claude flags (e.g., --dangerously-skip-permissions)
326
+ // Also sets permissions.defaultMode in .claude/settings.json
307
327
  if (feature === 'claudeflags') {
308
328
  const defaultFlags = options.flags || '--dangerously-skip-permissions';
329
+
330
+ // Map CLI flags to settings.json defaultMode values
331
+ const flagToMode = {
332
+ '--dangerously-skip-permissions': 'bypassPermissions',
333
+ '--permission-mode acceptEdits': 'acceptEdits',
334
+ };
335
+ const defaultMode = flagToMode[defaultFlags];
336
+
337
+ if (defaultMode) {
338
+ settings.permissions = settings.permissions || {};
339
+ settings.permissions.defaultMode = defaultMode;
340
+ writeJSON('.claude/settings.json', settings);
341
+ info(`Set permissions.defaultMode = "${defaultMode}" in .claude/settings.json`);
342
+ }
343
+
309
344
  updateMetadata(
310
345
  {
311
346
  features: {
@@ -321,6 +356,33 @@ function enableFeature(feature, options = {}, version) {
321
356
  );
322
357
  success(`Default Claude flags configured: ${defaultFlags}`);
323
358
  info('These flags will be passed to Claude when launched via "af" or "agileflow"');
359
+ if (defaultMode) {
360
+ info('Restart Claude Code for the new default mode to take effect');
361
+ }
362
+ return true;
363
+ }
364
+
365
+ // Handle agent teams - set env var in .claude/settings.json
366
+ if (feature === 'agentteams') {
367
+ settings.env = settings.env || {};
368
+ settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
369
+ writeJSON('.claude/settings.json', settings);
370
+ updateMetadata(
371
+ {
372
+ features: {
373
+ agentTeams: {
374
+ enabled: true,
375
+ version,
376
+ at: new Date().toISOString(),
377
+ },
378
+ },
379
+ },
380
+ version
381
+ );
382
+ success('Native Agent Teams enabled');
383
+ info('Set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 in .claude/settings.json');
384
+ info('Claude Code will use native TeamCreate/SendMessage tools');
385
+ info('Fallback: subagent mode (Task/TaskOutput) when native is unavailable');
324
386
  return true;
325
387
  }
326
388
 
@@ -391,9 +453,18 @@ function enableFeature(feature, options = {}, version) {
391
453
  return enableDamageControl(settings, options, version);
392
454
  }
393
455
 
456
+ const featureConfig = FEATURES[feature];
457
+ const contentHash = featureConfig?.script
458
+ ? hashFile(path.join(SCRIPTS_DIR, featureConfig.script))
459
+ : null;
394
460
  writeJSON('.claude/settings.json', settings);
395
461
  updateMetadata(
396
- { features: { [feature]: { enabled: true, version, at: new Date().toISOString() } } },
462
+ { features: { [feature]: {
463
+ enabled: true,
464
+ version,
465
+ ...(contentHash ? { contentHash } : {}),
466
+ at: new Date().toISOString(),
467
+ } } },
397
468
  version
398
469
  );
399
470
  updateGitignore();
@@ -558,6 +629,7 @@ function enableDamageControl(settings, options, version) {
558
629
 
559
630
  success('Damage control PreToolUse hooks enabled');
560
631
 
632
+ const primaryHash = hashFile(path.join(SCRIPTS_DIR, 'damage-control-bash.js'));
561
633
  updateMetadata(
562
634
  {
563
635
  features: {
@@ -565,6 +637,7 @@ function enableDamageControl(settings, options, version) {
565
637
  enabled: true,
566
638
  protectionLevel: level,
567
639
  version,
640
+ ...(primaryHash ? { contentHash: primaryHash } : {}),
568
641
  at: new Date().toISOString(),
569
642
  },
570
643
  },
@@ -710,8 +783,13 @@ function disableFeature(feature, version) {
710
783
  return true;
711
784
  }
712
785
 
713
- // Disable claude flags
786
+ // Disable claude flags - also reset permissions.defaultMode in settings.json
714
787
  if (feature === 'claudeflags') {
788
+ if (settings.permissions?.defaultMode) {
789
+ delete settings.permissions.defaultMode;
790
+ writeJSON('.claude/settings.json', settings);
791
+ info('Removed permissions.defaultMode from .claude/settings.json');
792
+ }
715
793
  updateMetadata(
716
794
  {
717
795
  features: {
@@ -727,6 +805,34 @@ function disableFeature(feature, version) {
727
805
  );
728
806
  success('Default Claude flags disabled');
729
807
  info('Claude will launch with default permissions (prompts for each action)');
808
+ info('Restart Claude Code for the change to take effect');
809
+ return true;
810
+ }
811
+
812
+ // Disable agent teams - remove env var from .claude/settings.json
813
+ if (feature === 'agentteams') {
814
+ if (settings.env) {
815
+ delete settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
816
+ if (Object.keys(settings.env).length === 0) {
817
+ delete settings.env;
818
+ }
819
+ }
820
+ writeJSON('.claude/settings.json', settings);
821
+ updateMetadata(
822
+ {
823
+ features: {
824
+ agentTeams: {
825
+ enabled: false,
826
+ version,
827
+ at: new Date().toISOString(),
828
+ },
829
+ },
830
+ },
831
+ version
832
+ );
833
+ success('Native Agent Teams disabled');
834
+ info('Removed CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS from .claude/settings.json');
835
+ info('AgileFlow will use subagent mode (Task/TaskOutput) for multi-agent orchestration');
730
836
  return true;
731
837
  }
732
838
 
@@ -9,6 +9,7 @@ const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const { execFileSync } = require('child_process');
11
11
  const { feedback } = require('../../lib/feedback');
12
+ const { tryOptional } = require('../../lib/errors');
12
13
  const {
13
14
  c,
14
15
  log,
@@ -115,11 +116,11 @@ function listScripts() {
115
116
  // Check if modified
116
117
  let isModified = false;
117
118
  if (exists && fileIndex?.files?.[`scripts/${script}`]) {
118
- try {
119
+ isModified = tryOptional(() => {
119
120
  const currentHash = sha256(fs.readFileSync(scriptPath));
120
121
  const indexHash = fileIndex.files[`scripts/${script}`].sha256;
121
- isModified = currentHash !== indexHash;
122
- } catch {}
122
+ return currentHash !== indexHash;
123
+ }, 'hash check') ?? false;
123
124
  }
124
125
 
125
126
  // Print status
@@ -279,9 +280,7 @@ function repairScripts(targetFeature = null) {
279
280
  if (fs.existsSync(srcPath)) {
280
281
  try {
281
282
  fs.copyFileSync(srcPath, destPath);
282
- try {
283
- fs.chmodSync(destPath, 0o755);
284
- } catch {}
283
+ tryOptional(() => fs.chmodSync(destPath, 0o755), 'chmod');
285
284
  success(`Restored ${script}`);
286
285
  repaired++;
287
286
  } catch (err) {
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const { tryOptional } = require('../../lib/errors');
9
10
 
10
11
  // ============================================================================
11
12
  // COLORS & LOGGING
@@ -58,9 +59,7 @@ const copyTemplate = (templateName, destPath) => {
58
59
  for (const src of sources) {
59
60
  if (fs.existsSync(src)) {
60
61
  fs.copyFileSync(src, destPath);
61
- try {
62
- fs.chmodSync(destPath, '755');
63
- } catch {}
62
+ tryOptional(() => fs.chmodSync(destPath, '755'), 'chmod');
64
63
  return true;
65
64
  }
66
65
  }
@@ -321,6 +321,23 @@ function generateSummary(prefetched = null, options = {}) {
321
321
 
322
322
  summary += divider();
323
323
 
324
+ // Scale indicator (EP-0033)
325
+ let scaleDetectorSummary;
326
+ try { scaleDetectorSummary = require('./scale-detector'); } catch { /* not available */ }
327
+ if (scaleDetectorSummary) {
328
+ try {
329
+ const scaleResult = scaleDetectorSummary.detectScale({
330
+ rootDir: process.cwd(),
331
+ statusJson: prefetched?.json?.statusJson,
332
+ sessionState: prefetched?.json?.sessionState,
333
+ });
334
+ const label = scaleDetectorSummary.getScaleLabel(scaleResult.scale);
335
+ summary += row('Scale', label, C.lavender, C.cyan);
336
+ } catch {
337
+ // Silently ignore
338
+ }
339
+ }
340
+
324
341
  // Key files
325
342
  const keyFileChecks = [
326
343
  { path: 'CLAUDE.md', label: 'CLAUDE' },
@@ -378,7 +395,7 @@ function generateSummary(prefetched = null, options = {}) {
378
395
  * @returns {string} Full content
379
396
  */
380
397
  function generateFullContent(prefetched = null, options = {}) {
381
- const { commandName = null, activeSections = [] } = options;
398
+ const { commandName = null, activeSections = [], smartDetectResults = null } = options;
382
399
 
383
400
  let content = '';
384
401
 
@@ -437,12 +454,17 @@ function generateFullContent(prefetched = null, options = {}) {
437
454
 
438
455
  if (askUserQuestionConfig?.enabled) {
439
456
  content += `${C.coral}${C.bold}┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓${C.reset}\n`;
440
- content += `${C.coral}${C.bold}┃ 🔔 MANDATORY: AskUserQuestion After EVERY Response ┃${C.reset}\n`;
457
+ content += `${C.coral}${C.bold}┃ 🔔 SMART AskUserQuestion After EVERY Response ┃${C.reset}\n`;
441
458
  content += `${C.coral}${C.bold}┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛${C.reset}\n`;
442
459
  content += `${C.bold}After completing ANY task${C.reset} (implementation, fix, etc.):\n`;
443
- content += `${C.mintGreen}→ ALWAYS${C.reset} call ${C.skyBlue}AskUserQuestion${C.reset} tool to offer next steps\n`;
444
- content += `${C.coral}→ NEVER${C.reset} end with text like "Done!" or "What's next?"\n\n`;
445
- content += `${C.dim}Balance: Use at natural pause points. Don't ask permission for routine work.${C.reset}\n\n`;
460
+ content += `${C.mintGreen}→ ALWAYS${C.reset} call ${C.skyBlue}AskUserQuestion${C.reset} tool with ${C.bold}SMART, contextual${C.reset} options\n`;
461
+ content += `${C.coral}→ NEVER${C.reset} generic options like "Continue" or "What next?"\n\n`;
462
+ content += `${C.bold}Smart Suggestion Principles:${C.reset}\n`;
463
+ content += `${C.mintGreen}→${C.reset} Always mark one option ${C.bold}"(Recommended)"${C.reset} based on logical next step\n`;
464
+ content += `${C.mintGreen}→${C.reset} Be specific: ${C.dim}"Run npm test for auth changes"${C.reset} not ${C.dim}"Run tests"${C.reset}\n`;
465
+ content += `${C.mintGreen}→${C.reset} Include context: ${C.dim}"3 files changed"${C.reset}, story IDs, test results\n`;
466
+ content += `${C.mintGreen}→${C.reset} Suggest workflow progression: plan → implement → test → commit\n`;
467
+ content += `${C.mintGreen}→${C.reset} After errors: suggest specific alternative, not ${C.dim}"fix it"${C.reset}\n\n`;
446
468
  }
447
469
 
448
470
  // CONTEXT BUDGET WARNING
@@ -479,6 +501,27 @@ function generateFullContent(prefetched = null, options = {}) {
479
501
  content += '\n';
480
502
  }
481
503
 
504
+ // SCALE DETECTION (EP-0033)
505
+ let scaleDetector;
506
+ try { scaleDetector = require('./scale-detector'); } catch { /* not available */ }
507
+ if (scaleDetector) {
508
+ try {
509
+ const scaleResult = scaleDetector.detectScale({
510
+ rootDir: process.cwd(),
511
+ statusJson: prefetched?.json?.statusJson,
512
+ sessionState: prefetched?.json?.sessionState,
513
+ });
514
+ const recs = scaleDetector.getScaleRecommendations(scaleResult.scale);
515
+ const label = scaleDetector.getScaleLabel(scaleResult.scale);
516
+ content += `\n${C.cyan}${C.bold}═══ Project Scale: ${label} ═══${C.reset}\n`;
517
+ content += `${C.dim}Detected: ${scaleResult.metrics.files} files, ${scaleResult.metrics.stories} stories, ${scaleResult.metrics.commits} commits (6mo)${C.reset}\n`;
518
+ content += `Planning depth: ${C.mintGreen}${recs.planningDepth}${C.reset} | Experts: ${C.mintGreen}${recs.expertCount}${C.reset}\n`;
519
+ content += `${C.dim}${recs.description}${C.reset}\n`;
520
+ } catch {
521
+ // Silently ignore scale detection errors
522
+ }
523
+ }
524
+
482
525
  // GIT STATUS
483
526
  content += `\n${C.skyBlue}${C.bold}═══ Git Status ═══${C.reset}\n`;
484
527
  const branch = prefetched?.git?.branch ?? safeExec('git branch --show-current') ?? 'unknown';
@@ -533,7 +576,7 @@ function generateFullContent(prefetched = null, options = {}) {
533
576
  if (Array.isArray(sessionState.active_commands) && sessionState.active_commands.length > 0) {
534
577
  const cmdNames = sessionState.active_commands.map(c => c.name).join(', ');
535
578
  content += `Active commands: ${C.skyBlue}${cmdNames}${C.reset}\n`;
536
- } else if (sessionState.active_command) {
579
+ } else if (sessionState.active_command && sessionState.active_command.name) {
537
580
  content += `Active command: ${C.skyBlue}${sessionState.active_command.name}${C.reset}\n`;
538
581
  }
539
582
 
@@ -592,7 +635,7 @@ function generateRemainingContent(prefetched, options = {}) {
592
635
  const sessionDir = story.claimedBy?.path
593
636
  ? path.basename(story.claimedBy.path)
594
637
  : 'unknown';
595
- content += ` ${C.coral}🔒${C.reset} ${C.lavender}${story.id}${C.reset} "${story.title}" ${C.dim}→ Session ${story.claimedBy?.session_id || '?'} (${sessionDir})${C.reset}\n`;
638
+ content += ` ${C.coral}🔒${C.reset} ${C.lavender}${story.id ?? '?'}${C.reset} "${story.title ?? 'untitled'}" ${C.dim}→ Session ${story.claimedBy?.session_id ?? '?'} (${sessionDir})${C.reset}\n`;
596
639
  });
597
640
  content += '\n';
598
641
  }
@@ -601,7 +644,7 @@ function generateRemainingContent(prefetched, options = {}) {
601
644
  if (myResult.ok && myResult.stories && myResult.stories.length > 0) {
602
645
  content += `\n${C.mintGreen}${C.bold}═══ ✓ Your Claimed Stories ═══${C.reset}\n`;
603
646
  myResult.stories.forEach(story => {
604
- content += ` ${C.mintGreen}✓${C.reset} ${C.lavender}${story.id}${C.reset} "${story.title}"\n`;
647
+ content += ` ${C.mintGreen}✓${C.reset} ${C.lavender}${story.id ?? '?'}${C.reset} "${story.title ?? 'untitled'}"\n`;
605
648
  });
606
649
  content += '\n';
607
650
  }
@@ -813,6 +856,42 @@ function generateRemainingContent(prefetched, options = {}) {
813
856
  }
814
857
  }
815
858
 
859
+ // SMART RECOMMENDATIONS (Contextual Feature Router)
860
+ const smartDetectResults = options.smartDetectResults;
861
+ if (smartDetectResults && !smartDetectResults.disabled) {
862
+ const { immediate, available, auto_enabled } = smartDetectResults.recommendations;
863
+ const hasRecommendations = immediate.length > 0 || available.length > 0;
864
+
865
+ if (hasRecommendations) {
866
+ content += `\n${C.brand}${C.bold}═══ Smart Recommendations ═══${C.reset}\n`;
867
+ content += `${C.dim}Phase: ${smartDetectResults.lifecycle_phase} (${smartDetectResults.phase_reason})${C.reset}\n`;
868
+
869
+ if (immediate.length > 0) {
870
+ content += `\n${C.coral}${C.bold}Immediate:${C.reset}\n`;
871
+ immediate.forEach(r => {
872
+ content += ` ${C.coral}!${C.reset} ${C.bold}${r.feature}${C.reset}: ${r.trigger} ${C.dim}(${r.command})${C.reset}\n`;
873
+ });
874
+ }
875
+
876
+ if (available.length > 0) {
877
+ content += `\n${C.skyBlue}Available:${C.reset}\n`;
878
+ available.slice(0, 5).forEach(r => {
879
+ content += ` ${C.skyBlue}>${C.reset} ${r.feature}: ${r.trigger} ${C.dim}(${r.command})${C.reset}\n`;
880
+ });
881
+ if (available.length > 5) {
882
+ content += ` ${C.dim}... and ${available.length - 5} more${C.reset}\n`;
883
+ }
884
+ }
885
+ }
886
+
887
+ // Show auto-enabled modes
888
+ const modes = auto_enabled || {};
889
+ const enabledModes = Object.entries(modes).filter(([, v]) => v).map(([k]) => k.replace('_', ' '));
890
+ if (enabledModes.length > 0) {
891
+ content += `\n${C.mintGreen}Auto-enabled:${C.reset} ${enabledModes.join(', ')}\n`;
892
+ }
893
+ }
894
+
816
895
  // FOOTER
817
896
  content += `\n${C.dim}─────────────────────────────────────────${C.reset}\n`;
818
897
  content += `${C.dim}Context gathered in single execution. Claude has full context.${C.reset}\n`;
@@ -33,6 +33,14 @@ const c = {
33
33
  reset: '\x1b[0m',
34
34
  };
35
35
 
36
+ // Pattern cache: avoids re-reading and re-parsing YAML on every hook invocation.
37
+ // Invalidated when the config file's mtime changes.
38
+ const _patternCache = {
39
+ /** @type {string|null} */ filePath: null,
40
+ /** @type {number} */ mtime: 0,
41
+ /** @type {object|null} */ config: null,
42
+ };
43
+
36
44
  // Shared constants
37
45
  const CONFIG_PATHS = [
38
46
  '.agileflow/config/damage-control-patterns.yaml',
@@ -70,8 +78,9 @@ function expandPath(p) {
70
78
  }
71
79
 
72
80
  /**
73
- * Load patterns configuration from YAML file
74
- * Returns empty config if not found (fail-open)
81
+ * Load patterns configuration from YAML file with caching.
82
+ * Returns cached config when the file hasn't changed (mtime check).
83
+ * Returns empty config if not found (fail-open).
75
84
  *
76
85
  * @param {string} projectRoot - Project root directory
77
86
  * @param {function} parseYAML - Function to parse YAML content
@@ -83,8 +92,23 @@ function loadPatterns(projectRoot, parseYAML, defaultConfig = {}) {
83
92
  const fullPath = path.join(projectRoot, configPath);
84
93
  if (fs.existsSync(fullPath)) {
85
94
  try {
95
+ // Check mtime for cache invalidation
96
+ const stat = fs.statSync(fullPath);
97
+ const mtime = stat.mtimeMs;
98
+
99
+ if (_patternCache.filePath === fullPath && _patternCache.mtime === mtime && _patternCache.config) {
100
+ return _patternCache.config;
101
+ }
102
+
86
103
  const content = fs.readFileSync(fullPath, 'utf8');
87
- return parseYAML(content);
104
+ const config = parseYAML(content);
105
+
106
+ // Store in cache
107
+ _patternCache.filePath = fullPath;
108
+ _patternCache.mtime = mtime;
109
+ _patternCache.config = config;
110
+
111
+ return config;
88
112
  } catch (e) {
89
113
  // Continue to next path
90
114
  }
@@ -95,6 +119,15 @@ function loadPatterns(projectRoot, parseYAML, defaultConfig = {}) {
95
119
  return defaultConfig;
96
120
  }
97
121
 
122
+ /**
123
+ * Clear the pattern cache (for testing or forced reload).
124
+ */
125
+ function clearPatternCache() {
126
+ _patternCache.filePath = null;
127
+ _patternCache.mtime = 0;
128
+ _patternCache.config = null;
129
+ }
130
+
98
131
  /**
99
132
  * Check if a file path matches any of the protected patterns
100
133
  *
@@ -559,6 +592,7 @@ module.exports = {
559
592
  findProjectRoot,
560
593
  expandPath,
561
594
  loadPatterns,
595
+ clearPatternCache,
562
596
  pathMatches,
563
597
  outputBlocked,
564
598
  runDamageControlHook,