agileflow 2.84.2 → 2.86.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.
@@ -21,10 +21,72 @@ const { execSync } = require('child_process');
21
21
  const { c: C, box } = require('../lib/colors');
22
22
  const { isValidCommandName } = require('../lib/validate');
23
23
 
24
- const DISPLAY_LIMIT = 30000; // Claude Code's Bash tool display limit
24
+ // Claude Code's Bash tool truncates around 30K chars, but ANSI codes and
25
+ // box-drawing characters (╭╮╰╯─│) are multi-byte UTF-8, so we need buffer.
26
+ // Summary table should be the LAST thing visible before truncation.
27
+ const DISPLAY_LIMIT = 29200;
25
28
 
26
- // Optional: Register command for PreCompact context preservation
29
+ // =============================================================================
30
+ // Progressive Disclosure: Section Activation
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Parse command-line arguments and determine which sections to activate.
35
+ * Sections are conditionally loaded based on parameters like MODE=loop.
36
+ *
37
+ * Section mapping:
38
+ * - MODE=loop → activates: loop-mode
39
+ * - Multi-session env → activates: multi-session
40
+ * - (Other triggers detected at runtime by the agent)
41
+ *
42
+ * @param {string[]} args - Command-line arguments after command name
43
+ * @returns {Object} { activeSections: string[], params: Object }
44
+ */
45
+ function parseCommandArgs(args) {
46
+ const activeSections = [];
47
+ const params = {};
48
+
49
+ for (const arg of args) {
50
+ // Parse KEY=VALUE arguments
51
+ const match = arg.match(/^([A-Z_]+)=(.+)$/i);
52
+ if (match) {
53
+ const [, key, value] = match;
54
+ params[key.toUpperCase()] = value;
55
+ }
56
+ }
57
+
58
+ // Activate sections based on parameters
59
+ if (params.MODE === 'loop') {
60
+ activeSections.push('loop-mode');
61
+ }
62
+
63
+ if (params.VISUAL === 'true') {
64
+ activeSections.push('visual-e2e');
65
+ }
66
+
67
+ // Check for multi-session environment
68
+ const registryPath = '.agileflow/sessions/registry.json';
69
+ if (fs.existsSync(registryPath)) {
70
+ try {
71
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
72
+ const sessionCount = Object.keys(registry.sessions || {}).length;
73
+ if (sessionCount > 1) {
74
+ activeSections.push('multi-session');
75
+ }
76
+ } catch {
77
+ // Silently ignore registry read errors
78
+ }
79
+ }
80
+
81
+ return { activeSections, params };
82
+ }
83
+
84
+ // Parse arguments
27
85
  const commandName = process.argv[2];
86
+ const commandArgs = process.argv.slice(3);
87
+ const { activeSections, params: commandParams } = parseCommandArgs(commandArgs);
88
+
89
+ // Register command for PreCompact context preservation
28
90
  if (commandName && isValidCommandName(commandName)) {
29
91
  const sessionStatePath = 'docs/09-agents/session-state.json';
30
92
  if (fs.existsSync(sessionStatePath)) {
@@ -39,11 +101,13 @@ if (commandName && isValidCommandName(commandName)) {
39
101
  // Remove any existing entry for this command (avoid duplicates)
40
102
  state.active_commands = state.active_commands.filter(c => c.name !== commandName);
41
103
 
42
- // Add the new command
104
+ // Add the new command with active sections for progressive disclosure
43
105
  state.active_commands.push({
44
106
  name: commandName,
45
107
  activated_at: new Date().toISOString(),
46
108
  state: {},
109
+ active_sections: activeSections,
110
+ params: commandParams,
47
111
  });
48
112
 
49
113
  // Remove legacy active_command field (only use active_commands array now)
@@ -259,6 +323,13 @@ function generateSummary() {
259
323
  summary += row('⭐ Up Next', readyStories.slice(0, 3).join(', '), C.skyBlue, C.skyBlue);
260
324
  }
261
325
 
326
+ // Progressive disclosure: Show active sections
327
+ if (activeSections.length > 0) {
328
+ summary += divider();
329
+ const sectionList = activeSections.join(', ');
330
+ summary += row('📖 Sections', sectionList, C.cyan, C.mintGreen);
331
+ }
332
+
262
333
  summary += divider();
263
334
 
264
335
  // Key files (using vibrant 256-color palette)
@@ -300,8 +371,6 @@ function generateSummary() {
300
371
  );
301
372
 
302
373
  summary += bottomBorder;
303
- summary += '\n';
304
- summary += `${C.dim}Full context continues below (Claude sees all)...${C.reset}\n\n`;
305
374
 
306
375
  return summary;
307
376
  }
@@ -317,6 +386,35 @@ function generateFullContent() {
317
386
  content += `${C.lavender}${C.bold}${title}${C.reset}\n`;
318
387
  content += `${C.dim}Generated: ${new Date().toISOString()}${C.reset}\n`;
319
388
 
389
+ // 0. PROGRESSIVE DISCLOSURE (section activation)
390
+ if (activeSections.length > 0) {
391
+ content += `\n${C.cyan}${C.bold}═══ 📖 Progressive Disclosure: Active Sections ═══${C.reset}\n`;
392
+ content += `${C.dim}The following sections are activated based on command parameters.${C.reset}\n`;
393
+ content += `${C.dim}Look for <!-- SECTION: name --> markers in the command file.${C.reset}\n\n`;
394
+
395
+ activeSections.forEach(section => {
396
+ content += ` ${C.mintGreen}✓${C.reset} ${C.bold}${section}${C.reset}\n`;
397
+ });
398
+
399
+ // Map sections to their triggers for context
400
+ const sectionDescriptions = {
401
+ 'loop-mode': 'Autonomous epic execution (MODE=loop)',
402
+ 'multi-session': 'Multi-session coordination detected',
403
+ 'visual-e2e': 'Visual screenshot verification (VISUAL=true)',
404
+ 'delegation': 'Expert spawning patterns (load when spawning)',
405
+ 'stuck': 'Research prompt guidance (load after 2 failures)',
406
+ 'plan-mode': 'Planning workflow details (load when entering plan mode)',
407
+ 'tools': 'Tool usage guidance (load when needed)',
408
+ };
409
+
410
+ content += `\n${C.dim}Section meanings:${C.reset}\n`;
411
+ activeSections.forEach(section => {
412
+ const desc = sectionDescriptions[section] || 'Conditional content';
413
+ content += ` ${C.dim}• ${section}: ${desc}${C.reset}\n`;
414
+ });
415
+ content += '\n';
416
+ }
417
+
320
418
  // 1. GIT STATUS (using vibrant 256-color palette)
321
419
  content += `\n${C.skyBlue}${C.bold}═══ Git Status ═══${C.reset}\n`;
322
420
  const branch = safeExec('git branch --show-current') || 'unknown';
@@ -375,6 +473,19 @@ function generateFullContent() {
375
473
  // Backwards compatibility for old format
376
474
  content += `Active command: ${C.skyBlue}${sessionState.active_command.name}${C.reset}\n`;
377
475
  }
476
+
477
+ // Show batch loop status if active
478
+ const batchLoop = sessionState.batch_loop;
479
+ if (batchLoop && batchLoop.enabled) {
480
+ content += `\n${C.skyBlue}${C.bold}── Batch Loop Active ──${C.reset}\n`;
481
+ content += `Pattern: ${C.cyan}${batchLoop.pattern}${C.reset}\n`;
482
+ content += `Action: ${C.cyan}${batchLoop.action}${C.reset}\n`;
483
+ content += `Current: ${C.lightYellow}${batchLoop.current_item || 'none'}${C.reset}\n`;
484
+ const summary = batchLoop.summary || {};
485
+ content += `Progress: ${C.lightGreen}${summary.completed || 0}${C.reset}/${summary.total || 0} `;
486
+ content += `(${C.lightYellow}${summary.in_progress || 0}${C.reset} in progress)\n`;
487
+ content += `Iteration: ${batchLoop.iteration || 0}/${batchLoop.max_iterations || 50}\n`;
488
+ }
378
489
  } else {
379
490
  content += `${C.dim}No session-state.json found${C.reset}\n`;
380
491
  }
@@ -439,9 +550,7 @@ function generateFullContent() {
439
550
 
440
551
  if (fs.existsSync(storyClaimingPath) || fs.existsSync(altStoryClaimingPath)) {
441
552
  try {
442
- const claimPath = fs.existsSync(storyClaimingPath)
443
- ? storyClaimingPath
444
- : altStoryClaimingPath;
553
+ const claimPath = fs.existsSync(storyClaimingPath) ? storyClaimingPath : altStoryClaimingPath;
445
554
  const storyClaiming = require(claimPath);
446
555
 
447
556
  // Get stories claimed by other sessions
@@ -450,7 +559,9 @@ function generateFullContent() {
450
559
  content += `\n${C.amber}${C.bold}═══ 🔒 Claimed Stories ═══${C.reset}\n`;
451
560
  content += `${C.dim}Stories locked by other sessions - pick a different one${C.reset}\n`;
452
561
  othersResult.stories.forEach(story => {
453
- const sessionDir = story.claimedBy?.path ? path.basename(story.claimedBy.path) : 'unknown';
562
+ const sessionDir = story.claimedBy?.path
563
+ ? path.basename(story.claimedBy.path)
564
+ : 'unknown';
454
565
  content += ` ${C.coral}🔒${C.reset} ${C.lavender}${story.id}${C.reset} "${story.title}" ${C.dim}→ Session ${story.claimedBy?.session_id || '?'} (${sessionDir})${C.reset}\n`;
455
566
  });
456
567
  content += '\n';
@@ -476,9 +587,7 @@ function generateFullContent() {
476
587
 
477
588
  if (fs.existsSync(fileTrackingPath) || fs.existsSync(altFileTrackingPath)) {
478
589
  try {
479
- const trackPath = fs.existsSync(fileTrackingPath)
480
- ? fileTrackingPath
481
- : altFileTrackingPath;
590
+ const trackPath = fs.existsSync(fileTrackingPath) ? fileTrackingPath : altFileTrackingPath;
482
591
  const fileTracking = require(trackPath);
483
592
 
484
593
  // Get file overlaps with other sessions
@@ -487,10 +596,12 @@ function generateFullContent() {
487
596
  content += `\n${C.amber}${C.bold}═══ ⚠️ File Overlaps ═══${C.reset}\n`;
488
597
  content += `${C.dim}Files also edited by other sessions - conflicts auto-resolved during merge${C.reset}\n`;
489
598
  overlapsResult.overlaps.forEach(overlap => {
490
- const sessionInfo = overlap.otherSessions.map(s => {
491
- const dir = path.basename(s.path);
492
- return `Session ${s.id} (${dir})`;
493
- }).join(', ');
599
+ const sessionInfo = overlap.otherSessions
600
+ .map(s => {
601
+ const dir = path.basename(s.path);
602
+ return `Session ${s.id} (${dir})`;
603
+ })
604
+ .join(', ');
494
605
  content += ` ${C.amber}⚠${C.reset} ${C.lavender}${overlap.file}${C.reset} ${C.dim}→ ${sessionInfo}${C.reset}\n`;
495
606
  });
496
607
  content += '\n';
@@ -523,7 +634,8 @@ function generateFullContent() {
523
634
  // 6. VISUAL E2E STATUS (detect from metadata or filesystem)
524
635
  const metadata = safeReadJSON('docs/00-meta/agileflow-metadata.json');
525
636
  const visualE2eConfig = metadata?.features?.visual_e2e;
526
- const playwrightExists = fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js');
637
+ const playwrightExists =
638
+ fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js');
527
639
  const screenshotsExists = fs.existsSync('screenshots');
528
640
  const testsE2eExists = fs.existsSync('tests/e2e');
529
641
 
@@ -706,11 +818,11 @@ if (fullContent.length <= cutoffPoint) {
706
818
  console.log(fullContent);
707
819
  console.log(summary);
708
820
  } else {
709
- // Split: content before cutoff + summary + content after cutoff
821
+ // Output content up to cutoff, then summary as the LAST visible thing.
822
+ // Don't output contentAfter - it would bleed into visible area before truncation,
823
+ // and Claude only sees ~30K chars from Bash anyway.
710
824
  const contentBefore = fullContent.substring(0, cutoffPoint);
711
- const contentAfter = fullContent.substring(cutoffPoint);
712
825
 
713
826
  console.log(contentBefore);
714
827
  console.log(summary);
715
- console.log(contentAfter);
716
828
  }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-boundary.js - PreToolUse hook for Edit/Write session isolation
4
+ *
5
+ * Prevents Claude from editing files outside the active session directory.
6
+ * Used with PreToolUse:Edit and PreToolUse:Write hooks.
7
+ *
8
+ * Exit codes:
9
+ * 0 = Allow the operation
10
+ * 2 = Block with message (shown to Claude)
11
+ *
12
+ * Input: JSON on stdin with tool_input.file_path
13
+ * Output: Error message to stderr if blocking
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ // Inline colors (no external dependency)
20
+ const c = {
21
+ coral: '\x1b[38;5;203m',
22
+ dim: '\x1b[2m',
23
+ reset: '\x1b[0m',
24
+ };
25
+
26
+ const STDIN_TIMEOUT_MS = 4000;
27
+
28
+ // Find project root by looking for .agileflow directory
29
+ function findProjectRoot() {
30
+ let dir = process.cwd();
31
+ while (dir !== '/') {
32
+ if (fs.existsSync(path.join(dir, '.agileflow'))) {
33
+ return dir;
34
+ }
35
+ if (fs.existsSync(path.join(dir, 'docs', '09-agents'))) {
36
+ return dir;
37
+ }
38
+ dir = path.dirname(dir);
39
+ }
40
+ return process.cwd();
41
+ }
42
+
43
+ const ROOT = findProjectRoot();
44
+ const SESSION_STATE_PATH = path.join(ROOT, 'docs', '09-agents', 'session-state.json');
45
+
46
+ // Get active session from session-state.json
47
+ function getActiveSession() {
48
+ try {
49
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
50
+ return null;
51
+ }
52
+ const data = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
53
+ return data.active_session || null;
54
+ } catch (e) {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // Check if filePath is inside sessionPath
60
+ function isInsideSession(filePath, sessionPath) {
61
+ const normalizedFile = path.resolve(filePath);
62
+ const normalizedSession = path.resolve(sessionPath);
63
+
64
+ return (
65
+ normalizedFile.startsWith(normalizedSession + path.sep) || normalizedFile === normalizedSession
66
+ );
67
+ }
68
+
69
+ // Output blocked message
70
+ function outputBlocked(filePath, activeSession) {
71
+ const sessionName = activeSession.nickname
72
+ ? `"${activeSession.nickname}"`
73
+ : `Session ${activeSession.id}`;
74
+
75
+ console.error(`${c.coral}[SESSION BOUNDARY]${c.reset} Edit blocked`);
76
+ console.error(`${c.dim}File: ${filePath}${c.reset}`);
77
+ console.error(`${c.dim}Active session: ${sessionName} (${activeSession.path})${c.reset}`);
78
+ console.error('');
79
+ console.error(`${c.dim}The file is outside the active session directory.${c.reset}`);
80
+ console.error(`${c.dim}Use /agileflow:session:resume to switch sessions first.${c.reset}`);
81
+ }
82
+
83
+ // Main logic - run with stdin events (async)
84
+ function main() {
85
+ let inputData = '';
86
+
87
+ process.stdin.setEncoding('utf8');
88
+
89
+ process.stdin.on('data', chunk => {
90
+ inputData += chunk;
91
+ });
92
+
93
+ process.stdin.on('end', () => {
94
+ try {
95
+ // Parse tool input from Claude Code
96
+ const hookData = JSON.parse(inputData);
97
+ const filePath = hookData?.tool_input?.file_path;
98
+
99
+ if (!filePath) {
100
+ // No file path in input - allow
101
+ process.exit(0);
102
+ }
103
+
104
+ // Get active session
105
+ const activeSession = getActiveSession();
106
+
107
+ if (!activeSession || !activeSession.path) {
108
+ // No active session set - allow all (normal behavior)
109
+ process.exit(0);
110
+ }
111
+
112
+ // Check if file is inside active session
113
+ if (isInsideSession(filePath, activeSession.path)) {
114
+ // File is inside active session - allow
115
+ process.exit(0);
116
+ }
117
+
118
+ // File is OUTSIDE active session - BLOCK
119
+ outputBlocked(filePath, activeSession);
120
+ process.exit(2);
121
+ } catch (e) {
122
+ // Parse error or other issue - fail open
123
+ process.exit(0);
124
+ }
125
+ });
126
+
127
+ // Handle no stdin (direct invocation)
128
+ process.stdin.on('error', () => {
129
+ process.exit(0);
130
+ });
131
+
132
+ // Set timeout to prevent hanging
133
+ setTimeout(() => {
134
+ process.exit(0);
135
+ }, STDIN_TIMEOUT_MS);
136
+ }
137
+
138
+ main();