agileflow 2.85.0 → 2.87.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.
@@ -117,19 +117,39 @@ function isSessionActive(sessionId) {
117
117
  return isPidAlive(parseInt(lock.pid, 10));
118
118
  }
119
119
 
120
- // Clean up stale locks
121
- function cleanupStaleLocks(registry) {
120
+ // Clean up stale locks (with detailed tracking)
121
+ function cleanupStaleLocks(registry, options = {}) {
122
+ const { verbose = false, dryRun = false } = options;
122
123
  let cleaned = 0;
124
+ const cleanedSessions = [];
123
125
 
124
126
  for (const [id, session] of Object.entries(registry.sessions)) {
125
127
  const lock = readLock(id);
126
- if (lock && !isPidAlive(parseInt(lock.pid, 10))) {
127
- removeLock(id);
128
- cleaned++;
128
+ if (lock) {
129
+ const pid = parseInt(lock.pid, 10);
130
+ const isAlive = isPidAlive(pid);
131
+
132
+ if (!isAlive) {
133
+ // Track what we're cleaning and why
134
+ cleanedSessions.push({
135
+ id,
136
+ nickname: session.nickname,
137
+ branch: session.branch,
138
+ pid,
139
+ reason: 'pid_dead',
140
+ path: session.path,
141
+ });
142
+
143
+ if (!dryRun) {
144
+ removeLock(id);
145
+ }
146
+ cleaned++;
147
+ }
129
148
  }
130
149
  }
131
150
 
132
- return cleaned;
151
+ // Return detailed info for display
152
+ return { count: cleaned, sessions: cleanedSessions };
133
153
  }
134
154
 
135
155
  // Get current git branch
@@ -156,8 +176,21 @@ function getCurrentStory() {
156
176
  return null;
157
177
  }
158
178
 
179
+ // Thread type enum values
180
+ const THREAD_TYPES = ['base', 'parallel', 'chained', 'fusion', 'big', 'long'];
181
+
182
+ // Auto-detect thread type from context
183
+ function detectThreadType(session, isWorktree = false) {
184
+ // Worktree sessions are parallel threads
185
+ if (isWorktree || (session && !session.is_main)) {
186
+ return 'parallel';
187
+ }
188
+ // Default to base
189
+ return 'base';
190
+ }
191
+
159
192
  // Register current session (called on startup)
160
- function registerSession(nickname = null) {
193
+ function registerSession(nickname = null, threadType = null) {
161
194
  const registry = loadRegistry();
162
195
  const cwd = process.cwd();
163
196
  const branch = getCurrentBranch();
@@ -179,6 +212,10 @@ function registerSession(nickname = null) {
179
212
  registry.sessions[existingId].story = story ? story.id : null;
180
213
  registry.sessions[existingId].last_active = new Date().toISOString();
181
214
  if (nickname) registry.sessions[existingId].nickname = nickname;
215
+ // Update thread_type if explicitly provided
216
+ if (threadType && THREAD_TYPES.includes(threadType)) {
217
+ registry.sessions[existingId].thread_type = threadType;
218
+ }
182
219
 
183
220
  writeLock(existingId, pid);
184
221
  saveRegistry(registry);
@@ -190,6 +227,11 @@ function registerSession(nickname = null) {
190
227
  const sessionId = String(registry.next_id);
191
228
  registry.next_id++;
192
229
 
230
+ const isMain = cwd === ROOT;
231
+ const detectedType = threadType && THREAD_TYPES.includes(threadType)
232
+ ? threadType
233
+ : detectThreadType(null, !isMain);
234
+
193
235
  registry.sessions[sessionId] = {
194
236
  path: cwd,
195
237
  branch,
@@ -197,13 +239,14 @@ function registerSession(nickname = null) {
197
239
  nickname: nickname || null,
198
240
  created: new Date().toISOString(),
199
241
  last_active: new Date().toISOString(),
200
- is_main: cwd === ROOT,
242
+ is_main: isMain,
243
+ thread_type: detectedType,
201
244
  };
202
245
 
203
246
  writeLock(sessionId, pid);
204
247
  saveRegistry(registry);
205
248
 
206
- return { id: sessionId, isNew: true };
249
+ return { id: sessionId, isNew: true, thread_type: detectedType };
207
250
  }
208
251
 
209
252
  // Unregister session (called on exit)
@@ -291,7 +334,7 @@ function createSession(options = {}) {
291
334
  };
292
335
  }
293
336
 
294
- // Register session
337
+ // Register session - worktree sessions are always parallel threads
295
338
  registry.next_id++;
296
339
  registry.sessions[sessionId] = {
297
340
  path: worktreePath,
@@ -301,6 +344,7 @@ function createSession(options = {}) {
301
344
  created: new Date().toISOString(),
302
345
  last_active: new Date().toISOString(),
303
346
  is_main: false,
347
+ thread_type: options.thread_type || 'parallel', // Worktrees default to parallel
304
348
  };
305
349
 
306
350
  saveRegistry(registry);
@@ -310,6 +354,7 @@ function createSession(options = {}) {
310
354
  sessionId,
311
355
  path: worktreePath,
312
356
  branch: branchName,
357
+ thread_type: registry.sessions[sessionId].thread_type,
313
358
  command: `cd "${worktreePath}" && claude`,
314
359
  };
315
360
  }
@@ -317,7 +362,7 @@ function createSession(options = {}) {
317
362
  // Get all sessions with status
318
363
  function getSessions() {
319
364
  const registry = loadRegistry();
320
- const cleaned = cleanupStaleLocks(registry);
365
+ const cleanupResult = cleanupStaleLocks(registry);
321
366
 
322
367
  const sessions = [];
323
368
  for (const [id, session] of Object.entries(registry.sessions)) {
@@ -332,7 +377,12 @@ function getSessions() {
332
377
  // Sort by ID (numeric)
333
378
  sessions.sort((a, b) => parseInt(a.id) - parseInt(b.id));
334
379
 
335
- return { sessions, cleaned };
380
+ // Return count for backward compat, plus detailed info
381
+ return {
382
+ sessions,
383
+ cleaned: cleanupResult.count,
384
+ cleanedSessions: cleanupResult.sessions,
385
+ };
336
386
  }
337
387
 
338
388
  // Get count of active sessions (excluding current)
@@ -671,6 +721,172 @@ function integrateSession(sessionId, options = {}) {
671
721
  return result;
672
722
  }
673
723
 
724
+ // Session phases for Kanban-style visualization
725
+ const SESSION_PHASES = {
726
+ TODO: 'todo',
727
+ CODING: 'coding',
728
+ REVIEW: 'review',
729
+ MERGED: 'merged',
730
+ };
731
+
732
+ // Detect session phase based on git state
733
+ function getSessionPhase(session) {
734
+ // If merged_at field exists, session was merged
735
+ if (session.merged_at) {
736
+ return SESSION_PHASES.MERGED;
737
+ }
738
+
739
+ // If is_main, it's the merged/main column
740
+ if (session.is_main) {
741
+ return SESSION_PHASES.MERGED;
742
+ }
743
+
744
+ // Check git state for the session
745
+ try {
746
+ const sessionPath = session.path;
747
+ if (!fs.existsSync(sessionPath)) {
748
+ return SESSION_PHASES.TODO;
749
+ }
750
+
751
+ // Count commits since branch diverged from main
752
+ const mainBranch = getMainBranch();
753
+ const commitCount = execSync(
754
+ `git rev-list --count ${mainBranch}..HEAD 2>/dev/null || echo 0`,
755
+ { cwd: sessionPath, encoding: 'utf8' }
756
+ ).trim();
757
+
758
+ const commits = parseInt(commitCount, 10);
759
+
760
+ if (commits === 0) {
761
+ return SESSION_PHASES.TODO;
762
+ }
763
+
764
+ // Check for uncommitted changes
765
+ const status = execSync('git status --porcelain 2>/dev/null || echo ""', {
766
+ cwd: sessionPath,
767
+ encoding: 'utf8',
768
+ }).trim();
769
+
770
+ if (status === '') {
771
+ // No uncommitted changes = ready for review
772
+ return SESSION_PHASES.REVIEW;
773
+ }
774
+
775
+ // Has commits but also uncommitted changes = still coding
776
+ return SESSION_PHASES.CODING;
777
+ } catch (e) {
778
+ // On error, assume coding phase
779
+ return SESSION_PHASES.CODING;
780
+ }
781
+ }
782
+
783
+ // Render Kanban-style board visualization
784
+ function renderKanbanBoard(sessions) {
785
+ const lines = [];
786
+
787
+ // Group sessions by phase
788
+ const byPhase = {
789
+ [SESSION_PHASES.TODO]: [],
790
+ [SESSION_PHASES.CODING]: [],
791
+ [SESSION_PHASES.REVIEW]: [],
792
+ [SESSION_PHASES.MERGED]: [],
793
+ };
794
+
795
+ for (const session of sessions) {
796
+ const phase = getSessionPhase(session);
797
+ byPhase[phase].push(session);
798
+ }
799
+
800
+ // Calculate column widths (min 12 chars)
801
+ const colWidth = 14;
802
+ const separator = ' ';
803
+
804
+ // Header
805
+ lines.push(`${c.cyan}Sessions (Kanban View):${c.reset}`);
806
+ lines.push('');
807
+
808
+ // Column headers
809
+ const headers = [
810
+ `${c.dim}TO DO${c.reset}`,
811
+ `${c.yellow}CODING${c.reset}`,
812
+ `${c.blue}REVIEW${c.reset}`,
813
+ `${c.green}MERGED${c.reset}`,
814
+ ];
815
+ lines.push(headers.map(h => h.padEnd(colWidth + 10)).join(separator)); // +10 for ANSI codes
816
+
817
+ // Top borders
818
+ const topBorder = `┌${'─'.repeat(colWidth)}┐`;
819
+ lines.push([topBorder, topBorder, topBorder, topBorder].join(separator));
820
+
821
+ // Find max rows needed
822
+ const maxRows = Math.max(
823
+ 1,
824
+ byPhase[SESSION_PHASES.TODO].length,
825
+ byPhase[SESSION_PHASES.CODING].length,
826
+ byPhase[SESSION_PHASES.REVIEW].length,
827
+ byPhase[SESSION_PHASES.MERGED].length
828
+ );
829
+
830
+ // Render rows
831
+ for (let i = 0; i < maxRows; i++) {
832
+ const cells = [
833
+ SESSION_PHASES.TODO,
834
+ SESSION_PHASES.CODING,
835
+ SESSION_PHASES.REVIEW,
836
+ SESSION_PHASES.MERGED,
837
+ ].map(phase => {
838
+ const session = byPhase[phase][i];
839
+ if (!session) {
840
+ return `│${' '.repeat(colWidth)}│`;
841
+ }
842
+
843
+ // Format session info
844
+ const id = `[${session.id}]`;
845
+ const name = session.nickname || session.branch || '';
846
+ const truncName = name.length > colWidth - 5 ? name.slice(0, colWidth - 8) + '...' : name;
847
+ const content = `${id} ${truncName}`.slice(0, colWidth);
848
+
849
+ return `│${content.padEnd(colWidth)}│`;
850
+ });
851
+ lines.push(cells.join(separator));
852
+
853
+ // Second line with story
854
+ const storyCells = [
855
+ SESSION_PHASES.TODO,
856
+ SESSION_PHASES.CODING,
857
+ SESSION_PHASES.REVIEW,
858
+ SESSION_PHASES.MERGED,
859
+ ].map(phase => {
860
+ const session = byPhase[phase][i];
861
+ if (!session) {
862
+ return `│${' '.repeat(colWidth)}│`;
863
+ }
864
+
865
+ const story = session.story || '-';
866
+ const storyTrunc = story.length > colWidth - 2 ? story.slice(0, colWidth - 5) + '...' : story;
867
+
868
+ return `│${c.dim}${storyTrunc.padEnd(colWidth)}${c.reset}│`;
869
+ });
870
+ lines.push(storyCells.join(separator));
871
+ }
872
+
873
+ // Bottom borders
874
+ const bottomBorder = `└${'─'.repeat(colWidth)}┘`;
875
+ lines.push([bottomBorder, bottomBorder, bottomBorder, bottomBorder].join(separator));
876
+
877
+ // Summary
878
+ lines.push('');
879
+ const summary = [
880
+ `${c.dim}To Do: ${byPhase[SESSION_PHASES.TODO].length}${c.reset}`,
881
+ `${c.yellow}Coding: ${byPhase[SESSION_PHASES.CODING].length}${c.reset}`,
882
+ `${c.blue}Review: ${byPhase[SESSION_PHASES.REVIEW].length}${c.reset}`,
883
+ `${c.green}Merged: ${byPhase[SESSION_PHASES.MERGED].length}${c.reset}`,
884
+ ].join(' │ ');
885
+ lines.push(summary);
886
+
887
+ return lines.join('\n');
888
+ }
889
+
674
890
  // Format sessions for display
675
891
  function formatSessionsTable(sessions) {
676
892
  const lines = [];
@@ -747,6 +963,11 @@ function main() {
747
963
  const { sessions, cleaned } = getSessions();
748
964
  if (args.includes('--json')) {
749
965
  console.log(JSON.stringify({ sessions, cleaned }));
966
+ } else if (args.includes('--kanban')) {
967
+ console.log(renderKanbanBoard(sessions));
968
+ if (cleaned > 0) {
969
+ console.log(`${c.dim}Cleaned ${cleaned} stale lock(s)${c.reset}`);
970
+ }
750
971
  } else {
751
972
  console.log(formatSessionsTable(sessions));
752
973
  if (cleaned > 0) {
@@ -793,7 +1014,7 @@ function main() {
793
1014
 
794
1015
  // Register in single pass (combines register + count + status)
795
1016
  const registry = loadRegistry();
796
- const cleaned = cleanupStaleLocks(registry);
1017
+ const cleanupResult = cleanupStaleLocks(registry);
797
1018
  const branch = getCurrentBranch();
798
1019
  const story = getCurrentStory();
799
1020
  const pid = process.ppid || process.pid;
@@ -814,11 +1035,16 @@ function main() {
814
1035
  registry.sessions[sessionId].story = story ? story.id : null;
815
1036
  registry.sessions[sessionId].last_active = new Date().toISOString();
816
1037
  if (nickname) registry.sessions[sessionId].nickname = nickname;
1038
+ // Ensure thread_type exists (migration for old sessions)
1039
+ if (!registry.sessions[sessionId].thread_type) {
1040
+ registry.sessions[sessionId].thread_type = registry.sessions[sessionId].is_main ? 'base' : 'parallel';
1041
+ }
817
1042
  writeLock(sessionId, pid);
818
1043
  } else {
819
1044
  // Create new
820
1045
  sessionId = String(registry.next_id);
821
1046
  registry.next_id++;
1047
+ const isMain = cwd === ROOT;
822
1048
  registry.sessions[sessionId] = {
823
1049
  path: cwd,
824
1050
  branch,
@@ -826,7 +1052,8 @@ function main() {
826
1052
  nickname: nickname || null,
827
1053
  created: new Date().toISOString(),
828
1054
  last_active: new Date().toISOString(),
829
- is_main: cwd === ROOT,
1055
+ is_main: isMain,
1056
+ thread_type: isMain ? 'base' : 'parallel',
830
1057
  };
831
1058
  writeLock(sessionId, pid);
832
1059
  isNew = true;
@@ -853,7 +1080,8 @@ function main() {
853
1080
  current,
854
1081
  otherActive,
855
1082
  total: sessions.length,
856
- cleaned,
1083
+ cleaned: cleanupResult.count,
1084
+ cleanedSessions: cleanupResult.sessions,
857
1085
  })
858
1086
  );
859
1087
  break;
@@ -984,6 +1212,26 @@ function main() {
984
1212
  break;
985
1213
  }
986
1214
 
1215
+ case 'thread-type': {
1216
+ const subCommand = args[1];
1217
+ if (subCommand === 'set') {
1218
+ const sessionId = args[2];
1219
+ const threadType = args[3];
1220
+ if (!sessionId || !threadType) {
1221
+ console.log(JSON.stringify({ success: false, error: 'Usage: thread-type set <sessionId> <type>' }));
1222
+ return;
1223
+ }
1224
+ const result = setSessionThreadType(sessionId, threadType);
1225
+ console.log(JSON.stringify(result));
1226
+ } else {
1227
+ // Default: get thread type
1228
+ const sessionId = args[1] || null;
1229
+ const result = getSessionThreadType(sessionId);
1230
+ console.log(JSON.stringify(result));
1231
+ }
1232
+ break;
1233
+ }
1234
+
987
1235
  case 'help':
988
1236
  default:
989
1237
  console.log(`
@@ -1001,6 +1249,8 @@ ${c.cyan}Commands:${c.reset}
1001
1249
  switch <id|nickname> Switch active session context (for /add-dir)
1002
1250
  active Get currently switched session (if any)
1003
1251
  clear-active Clear switched session (back to main)
1252
+ thread-type [id] Get thread type for session (default: current)
1253
+ thread-type set <id> <type> Set thread type (base|parallel|chained|fusion|big|long)
1004
1254
  check-merge <id> Check if session is mergeable to main
1005
1255
  merge-preview <id> Preview commits/files to be merged
1006
1256
  integrate <id> [opts] Merge session to main and cleanup
@@ -1186,7 +1436,7 @@ function smartMerge(sessionId, options = {}) {
1186
1436
  }
1187
1437
 
1188
1438
  // Categorize and plan resolutions
1189
- const resolutions = conflictFiles.files.map((file) => {
1439
+ const resolutions = conflictFiles.files.map(file => {
1190
1440
  const category = categorizeFile(file);
1191
1441
  const strategyInfo = getMergeStrategy(category);
1192
1442
  return {
@@ -1295,7 +1545,10 @@ function smartMerge(sessionId, options = {}) {
1295
1545
  result.worktreeDeleted = true;
1296
1546
  } catch (e) {
1297
1547
  try {
1298
- execSync(`git worktree remove --force "${session.path}"`, { cwd: ROOT, encoding: 'utf8' });
1548
+ execSync(`git worktree remove --force "${session.path}"`, {
1549
+ cwd: ROOT,
1550
+ encoding: 'utf8',
1551
+ });
1299
1552
  result.worktreeDeleted = true;
1300
1553
  } catch (e2) {
1301
1554
  result.worktreeDeleted = false;
@@ -1395,7 +1648,7 @@ function getConflictingFiles(sessionId) {
1395
1648
  const branchSet = new Set((branchFiles.stdout || '').trim().split('\n').filter(Boolean));
1396
1649
 
1397
1650
  // Find intersection (files changed in both)
1398
- const conflicting = [...mainSet].filter((f) => branchSet.has(f));
1651
+ const conflicting = [...mainSet].filter(f => branchSet.has(f));
1399
1652
 
1400
1653
  return { success: true, files: conflicting };
1401
1654
  }
@@ -1612,6 +1865,65 @@ function getActiveSession() {
1612
1865
  }
1613
1866
  }
1614
1867
 
1868
+ /**
1869
+ * Get thread type for a session.
1870
+ * @param {string} sessionId - Session ID (or null for current session)
1871
+ * @returns {{ success: boolean, thread_type?: string, error?: string }}
1872
+ */
1873
+ function getSessionThreadType(sessionId = null) {
1874
+ const registry = loadRegistry();
1875
+ const cwd = process.cwd();
1876
+
1877
+ // Find session
1878
+ let targetId = sessionId;
1879
+ if (!targetId) {
1880
+ // Find current session by path
1881
+ for (const [id, session] of Object.entries(registry.sessions)) {
1882
+ if (session.path === cwd) {
1883
+ targetId = id;
1884
+ break;
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ if (!targetId || !registry.sessions[targetId]) {
1890
+ return { success: false, error: 'Session not found' };
1891
+ }
1892
+
1893
+ const session = registry.sessions[targetId];
1894
+ // Return thread_type or auto-detect for legacy sessions
1895
+ const threadType = session.thread_type || (session.is_main ? 'base' : 'parallel');
1896
+
1897
+ return {
1898
+ success: true,
1899
+ thread_type: threadType,
1900
+ session_id: targetId,
1901
+ is_main: session.is_main,
1902
+ };
1903
+ }
1904
+
1905
+ /**
1906
+ * Update thread type for a session.
1907
+ * @param {string} sessionId - Session ID
1908
+ * @param {string} threadType - New thread type
1909
+ * @returns {{ success: boolean, error?: string }}
1910
+ */
1911
+ function setSessionThreadType(sessionId, threadType) {
1912
+ if (!THREAD_TYPES.includes(threadType)) {
1913
+ return { success: false, error: `Invalid thread type: ${threadType}. Valid: ${THREAD_TYPES.join(', ')}` };
1914
+ }
1915
+
1916
+ const registry = loadRegistry();
1917
+ if (!registry.sessions[sessionId]) {
1918
+ return { success: false, error: `Session ${sessionId} not found` };
1919
+ }
1920
+
1921
+ registry.sessions[sessionId].thread_type = threadType;
1922
+ saveRegistry(registry);
1923
+
1924
+ return { success: true, thread_type: threadType };
1925
+ }
1926
+
1615
1927
  // Export for use as module
1616
1928
  module.exports = {
1617
1929
  loadRegistry,
@@ -1639,6 +1951,15 @@ module.exports = {
1639
1951
  switchSession,
1640
1952
  clearActiveSession,
1641
1953
  getActiveSession,
1954
+ // Thread type tracking
1955
+ THREAD_TYPES,
1956
+ detectThreadType,
1957
+ getSessionThreadType,
1958
+ setSessionThreadType,
1959
+ // Kanban visualization
1960
+ SESSION_PHASES,
1961
+ getSessionPhase,
1962
+ renderKanbanBoard,
1642
1963
  };
1643
1964
 
1644
1965
  // Run CLI if executed directly
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-session-boundary.js - Test session boundary hook logic
4
+ *
5
+ * Usage:
6
+ * node test-session-boundary.js --active=/path/to/session --file=/path/to/file
7
+ *
8
+ * Examples:
9
+ * node test-session-boundary.js --active=/home/coder/project-bugfix --file=/home/coder/project-bugfix/src/App.tsx
10
+ * → ALLOWED (file is inside active session)
11
+ *
12
+ * node test-session-boundary.js --active=/home/coder/project-bugfix --file=/home/coder/project/src/App.tsx
13
+ * → BLOCKED (file is outside active session)
14
+ */
15
+
16
+ const path = require('path');
17
+
18
+ // Parse arguments
19
+ const args = process.argv.slice(2);
20
+ let activeSessionPath = null;
21
+ let filePath = null;
22
+
23
+ for (const arg of args) {
24
+ if (arg.startsWith('--active=')) {
25
+ activeSessionPath = arg.slice('--active='.length);
26
+ } else if (arg.startsWith('--file=')) {
27
+ filePath = arg.slice('--file='.length);
28
+ }
29
+ }
30
+
31
+ // Show usage if missing args
32
+ if (!activeSessionPath || !filePath) {
33
+ console.log(`
34
+ Session Boundary Hook Tester
35
+
36
+ Usage:
37
+ node test-session-boundary.js --active=<session_path> --file=<file_path>
38
+
39
+ Examples:
40
+ # File INSIDE active session (should be ALLOWED)
41
+ node test-session-boundary.js \\
42
+ --active=/home/coder/project-bugfix \\
43
+ --file=/home/coder/project-bugfix/src/App.tsx
44
+
45
+ # File OUTSIDE active session (should be BLOCKED)
46
+ node test-session-boundary.js \\
47
+ --active=/home/coder/project-bugfix \\
48
+ --file=/home/coder/project/src/App.tsx
49
+ `);
50
+ process.exit(0);
51
+ }
52
+
53
+ // Normalize paths
54
+ const normalizedActive = path.resolve(activeSessionPath);
55
+ const normalizedFile = path.resolve(filePath);
56
+
57
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
58
+ console.log('Session Boundary Check');
59
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
60
+
61
+ console.log(`Active Session Path: ${normalizedActive}`);
62
+ console.log(`File Being Edited: ${normalizedFile}`);
63
+ console.log('');
64
+
65
+ // Check if file is within active session path
66
+ const isInsideSession = normalizedFile.startsWith(normalizedActive + path.sep) ||
67
+ normalizedFile === normalizedActive;
68
+
69
+ if (isInsideSession) {
70
+ console.log('✅ ALLOWED - File is inside the active session directory');
71
+ console.log('');
72
+ console.log('The hook would exit(0) and allow this edit.');
73
+ } else {
74
+ console.log('❌ BLOCKED - File is OUTSIDE the active session directory!');
75
+ console.log('');
76
+ console.log('The hook would exit(2) and block this edit with message:');
77
+ console.log(` "Edit blocked: ${normalizedFile} is outside active session ${normalizedActive}"`);
78
+ }
79
+
80
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
@@ -447,8 +447,46 @@ Before implementing, evaluate task complexity:
447
447
  3. Design implementation approach
448
448
  4. Present plan with file paths and steps
449
449
  5. Clarify decisions with user
450
- 6. Get approval `ExitPlanMode`
451
- 7. Implement the approved plan
450
+ 6. **PLAN REVIEW CHECKPOINT** (see below)
451
+ 7. Get approval `ExitPlanMode`
452
+ 8. Implement the approved plan
453
+
454
+ ### Plan Review Checkpoint (CRITICAL)
455
+
456
+ **Before transitioning from plan → implement, ALWAYS display:**
457
+
458
+ ```markdown
459
+ ---
460
+
461
+ ## Plan Review Checkpoint
462
+
463
+ **Leverage Reminder:**
464
+ ```
465
+ Bad line of code = 1 bad line
466
+ Bad part of plan = 100+ bad lines
467
+ Bad line of research = entire direction is hosed
468
+ ```
469
+
470
+ Take time to review this plan. A few minutes now saves hours later.
471
+
472
+ **Review Checklist:**
473
+ - [ ] Does this approach make sense for the codebase?
474
+ - [ ] Are there simpler alternatives?
475
+ - [ ] Will this cause breaking changes?
476
+ - [ ] Are edge cases covered?
477
+
478
+ **Approve plan and proceed to implementation?**
479
+ ```
480
+
481
+ **Why this matters:**
482
+ - Plans are the highest-leverage checkpoint for human review
483
+ - Catching a bad approach before coding saves 100s of lines of rework
484
+ - This is where to invest review time (not code review)
485
+
486
+ **Options to present:**
487
+ 1. **Approve** - Proceed to implementation
488
+ 2. **Iterate** - Modify the plan first
489
+ 3. **Research** - Need more context before deciding
452
490
 
453
491
  **Plan Quality Checklist**:
454
492
  - [ ] Explored relevant codebase