aiwcli 0.9.0 → 0.9.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.
- package/README.md +19 -35
- package/dist/lib/template-installer.js +38 -0
- package/dist/templates/_shared/.claude/commands/handoff.md +219 -7
- package/dist/templates/_shared/.codex/workflows/handoff.md +219 -7
- package/dist/templates/_shared/.windsurf/workflows/handoff.md +219 -7
- package/dist/templates/_shared/hooks/context_enforcer.py +9 -5
- package/dist/templates/_shared/hooks/context_monitor.py +28 -10
- package/dist/templates/_shared/hooks/file-suggestion.py +45 -15
- package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -10
- package/dist/templates/_shared/lib/base/constants.py +45 -0
- package/dist/templates/_shared/lib/base/inference.py +44 -21
- package/dist/templates/_shared/lib/base/subprocess_utils.py +46 -0
- package/dist/templates/_shared/lib/base/utils.py +5 -3
- package/dist/templates/_shared/lib/context/__init__.py +0 -8
- package/dist/templates/_shared/lib/context/cache.py +2 -4
- package/dist/templates/_shared/lib/context/context_manager.py +1 -118
- package/dist/templates/_shared/lib/context/discovery.py +8 -50
- package/dist/templates/_shared/lib/handoff/document_generator.py +2 -5
- package/dist/templates/_shared/lib/templates/README.md +0 -1
- package/dist/templates/_shared/lib/templates/formatters.py +0 -1
- package/dist/templates/_shared/scripts/save_handoff.py +289 -43
- package/dist/templates/_shared/workflows/handoff.md +30 -16
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +41 -20
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +9 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +9 -0
- package/dist/templates/cc-native/_cc-native/lib/utils.py +123 -10
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -1,40 +1,221 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Save a handoff document
|
|
2
|
+
"""Save a handoff document with folder-based sharding.
|
|
3
3
|
|
|
4
4
|
Usage:
|
|
5
5
|
python .aiwcli/_shared/scripts/save_handoff.py <context_id> <<'EOF'
|
|
6
|
-
# Your handoff markdown content here
|
|
6
|
+
# Your handoff markdown content here (with <!-- SECTION: name --> markers)
|
|
7
7
|
EOF
|
|
8
8
|
|
|
9
9
|
Or with a file:
|
|
10
10
|
python .aiwcli/_shared/scripts/save_handoff.py <context_id> < handoff.md
|
|
11
11
|
|
|
12
12
|
This script:
|
|
13
|
-
1.
|
|
14
|
-
2.
|
|
15
|
-
3.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
1. Parses sections from incoming markdown using <!-- SECTION: name --> markers
|
|
14
|
+
2. Creates a timestamped folder at _output/contexts/{context_id}/handoffs/{YYYY-MM-DD-HHMM}/
|
|
15
|
+
3. Writes sharded files:
|
|
16
|
+
- index.md (main entry point with navigation)
|
|
17
|
+
- completed-work.md, dead-ends.md, decisions.md, pending.md, context.md
|
|
18
|
+
- plan.md (copy of original plan if it exists)
|
|
19
|
+
4. Records the event in events.jsonl (informational only)
|
|
19
20
|
"""
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
20
24
|
import sys
|
|
21
25
|
from datetime import datetime
|
|
22
26
|
from pathlib import Path
|
|
27
|
+
from typing import Dict, Optional
|
|
23
28
|
|
|
24
29
|
# Add parent directories to path for imports
|
|
25
30
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
26
31
|
SHARED_ROOT = SCRIPT_DIR.parent
|
|
27
32
|
sys.path.insert(0, str(SHARED_ROOT))
|
|
28
33
|
|
|
29
|
-
from lib.context.context_manager import
|
|
30
|
-
from lib.base.utils import eprint
|
|
34
|
+
from lib.context.context_manager import get_context
|
|
35
|
+
from lib.base.utils import eprint
|
|
36
|
+
from lib.base.atomic_write import atomic_write
|
|
37
|
+
from lib.base.constants import get_handoff_folder_path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_frontmatter(content: str) -> tuple[Dict[str, str], str]:
|
|
41
|
+
"""Parse YAML frontmatter from markdown content.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple of (frontmatter dict, remaining content)
|
|
45
|
+
"""
|
|
46
|
+
frontmatter = {}
|
|
47
|
+
remaining = content
|
|
48
|
+
|
|
49
|
+
if content.startswith('---'):
|
|
50
|
+
parts = content.split('---', 2)
|
|
51
|
+
if len(parts) >= 3:
|
|
52
|
+
fm_lines = parts[1].strip().split('\n')
|
|
53
|
+
for line in fm_lines:
|
|
54
|
+
if ':' in line:
|
|
55
|
+
key, value = line.split(':', 1)
|
|
56
|
+
frontmatter[key.strip()] = value.strip()
|
|
57
|
+
remaining = parts[2].strip()
|
|
58
|
+
|
|
59
|
+
return frontmatter, remaining
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_handoff_sections(content: str) -> Dict[str, str]:
|
|
63
|
+
"""Parse markdown content by section markers.
|
|
64
|
+
|
|
65
|
+
Looks for <!-- SECTION: name --> markers and extracts content between them.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dict mapping section names to their content
|
|
69
|
+
"""
|
|
70
|
+
sections = {}
|
|
71
|
+
current_section = None
|
|
72
|
+
current_content = []
|
|
73
|
+
|
|
74
|
+
for line in content.split('\n'):
|
|
75
|
+
if line.strip().startswith('<!-- SECTION:'):
|
|
76
|
+
# Save previous section
|
|
77
|
+
if current_section:
|
|
78
|
+
sections[current_section] = '\n'.join(current_content).strip()
|
|
79
|
+
# Extract new section name
|
|
80
|
+
match = re.search(r'<!-- SECTION:\s*(\S+)\s*-->', line)
|
|
81
|
+
if match:
|
|
82
|
+
current_section = match.group(1)
|
|
83
|
+
current_content = []
|
|
84
|
+
elif current_section:
|
|
85
|
+
current_content.append(line)
|
|
86
|
+
|
|
87
|
+
# Save final section
|
|
88
|
+
if current_section:
|
|
89
|
+
sections[current_section] = '\n'.join(current_content).strip()
|
|
90
|
+
|
|
91
|
+
return sections
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_git_status() -> str:
|
|
95
|
+
"""Get current git status."""
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
['git', 'status', '--short'],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
timeout=5
|
|
102
|
+
)
|
|
103
|
+
return result.stdout.strip() or "(no changes)"
|
|
104
|
+
except Exception:
|
|
105
|
+
return "(git status unavailable)"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_plan_path_from_context(context_id: str, project_root: Path) -> Optional[Path]:
|
|
109
|
+
"""Get the plan path from context.json if available."""
|
|
110
|
+
context = get_context(context_id, project_root)
|
|
111
|
+
if not context or not context.in_flight:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
artifact_path = context.in_flight.artifact_path
|
|
115
|
+
if artifact_path:
|
|
116
|
+
plan_path = Path(artifact_path)
|
|
117
|
+
if plan_path.exists():
|
|
118
|
+
return plan_path
|
|
119
|
+
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_index(
|
|
124
|
+
frontmatter: Dict[str, str],
|
|
125
|
+
sections: Dict[str, str],
|
|
126
|
+
git_status: str,
|
|
127
|
+
has_plan: bool,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Generate the index.md file with summary and navigation."""
|
|
130
|
+
now = datetime.now()
|
|
131
|
+
|
|
132
|
+
lines = [
|
|
133
|
+
"---",
|
|
134
|
+
"type: handoff",
|
|
135
|
+
f"context_id: {frontmatter.get('context_id', 'unknown')}",
|
|
136
|
+
f"created_at: {now.isoformat()}",
|
|
137
|
+
f"session_id: {frontmatter.get('session_id', 'unknown')}",
|
|
138
|
+
f"project: {frontmatter.get('project', 'unknown')}",
|
|
139
|
+
f"plan_path: {frontmatter.get('plan_document', 'none')}",
|
|
140
|
+
"---",
|
|
141
|
+
"",
|
|
142
|
+
f"# Session Handoff - {now.strftime('%Y-%m-%d %H:%M')}",
|
|
143
|
+
"",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Summary section
|
|
147
|
+
summary = sections.get('summary', '').strip()
|
|
148
|
+
if summary:
|
|
149
|
+
# Extract just the content (skip the ## Summary header if present)
|
|
150
|
+
summary_lines = summary.split('\n')
|
|
151
|
+
summary_text = '\n'.join(
|
|
152
|
+
line for line in summary_lines
|
|
153
|
+
if not line.strip().startswith('##')
|
|
154
|
+
).strip()
|
|
155
|
+
lines.extend([
|
|
156
|
+
"## Summary",
|
|
157
|
+
summary_text,
|
|
158
|
+
"",
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
# Navigation table
|
|
162
|
+
lines.extend([
|
|
163
|
+
"## Quick Navigation",
|
|
164
|
+
"",
|
|
165
|
+
"| Document | Purpose | Priority |",
|
|
166
|
+
"|----------|---------|----------|",
|
|
167
|
+
"| [Dead Ends](./dead-ends.md) | Failed approaches - DO NOT RETRY | Read First |",
|
|
168
|
+
"| [Pending](./pending.md) | Next steps and blockers | Action Items |",
|
|
169
|
+
"| [Completed Work](./completed-work.md) | Tasks finished this session | Reference |",
|
|
170
|
+
"| [Decisions](./decisions.md) | Technical choices and rationale | Reference |",
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
if has_plan:
|
|
174
|
+
lines.append("| [Plan](./plan.md) | Original plan being implemented | Reference |")
|
|
175
|
+
|
|
176
|
+
lines.extend([
|
|
177
|
+
"| [Context](./context.md) | External requirements and notes | Reference |",
|
|
178
|
+
"",
|
|
179
|
+
"## Continuation Instructions",
|
|
180
|
+
"",
|
|
181
|
+
"To continue this work in a new session:",
|
|
182
|
+
"1. This index document provides the overview",
|
|
183
|
+
"2. **Read [Dead Ends](./dead-ends.md) first** to avoid repeating failed approaches",
|
|
184
|
+
"3. Check [Pending](./pending.md) for immediate next steps",
|
|
185
|
+
"4. Reference other documents as needed",
|
|
186
|
+
"",
|
|
187
|
+
"## Git Status at Handoff",
|
|
188
|
+
"```",
|
|
189
|
+
git_status,
|
|
190
|
+
"```",
|
|
191
|
+
"",
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
return '\n'.join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_section_file(folder: Path, filename: str, title: str, content: str) -> bool:
|
|
198
|
+
"""Write a section file with header."""
|
|
199
|
+
lines = [
|
|
200
|
+
f"# {title}",
|
|
201
|
+
"",
|
|
202
|
+
content if content else "(No content for this section)",
|
|
203
|
+
"",
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
file_path = folder / filename
|
|
207
|
+
success, error = atomic_write(file_path, '\n'.join(lines))
|
|
208
|
+
if not success:
|
|
209
|
+
eprint(f"[save_handoff] Warning: Failed to write {filename}: {error}")
|
|
210
|
+
return False
|
|
211
|
+
return True
|
|
31
212
|
|
|
32
213
|
|
|
33
214
|
def main():
|
|
34
215
|
if len(sys.argv) < 2:
|
|
35
216
|
print("Usage: python save_handoff.py <context_id> < content.md", file=sys.stderr)
|
|
36
217
|
print(" python save_handoff.py <context_id> <<'EOF'", file=sys.stderr)
|
|
37
|
-
print(" ... markdown content ...", file=sys.stderr)
|
|
218
|
+
print(" ... markdown content with <!-- SECTION: name --> markers ...", file=sys.stderr)
|
|
38
219
|
print(" EOF", file=sys.stderr)
|
|
39
220
|
sys.exit(1)
|
|
40
221
|
|
|
@@ -55,44 +236,109 @@ def main():
|
|
|
55
236
|
print(f"Error: Context not found: {context_id}", file=sys.stderr)
|
|
56
237
|
sys.exit(1)
|
|
57
238
|
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
239
|
+
# Parse frontmatter and sections
|
|
240
|
+
frontmatter, body = parse_frontmatter(content)
|
|
241
|
+
sections = parse_handoff_sections(body)
|
|
61
242
|
|
|
62
|
-
|
|
63
|
-
timestamp = datetime.now().strftime("%H%M")
|
|
64
|
-
filename = f"HANDOFF-{timestamp}.md"
|
|
65
|
-
file_path = handoffs_dir / filename
|
|
243
|
+
eprint(f"[save_handoff] Parsed {len(sections)} sections: {list(sections.keys())}")
|
|
66
244
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
file_path = handoffs_dir / filename
|
|
72
|
-
counter += 1
|
|
245
|
+
# Create handoff folder
|
|
246
|
+
handoff_folder = get_handoff_folder_path(context_id, project_root)
|
|
247
|
+
handoff_folder.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
eprint(f"[save_handoff] Created folder: {handoff_folder}")
|
|
73
249
|
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
250
|
+
# Get git status
|
|
251
|
+
git_status = get_git_status()
|
|
252
|
+
|
|
253
|
+
# Check for plan
|
|
254
|
+
plan_path = get_plan_path_from_context(context_id, project_root)
|
|
255
|
+
has_plan = plan_path is not None
|
|
256
|
+
|
|
257
|
+
# Copy plan if exists
|
|
258
|
+
if plan_path:
|
|
259
|
+
try:
|
|
260
|
+
plan_content = plan_path.read_text(encoding='utf-8')
|
|
261
|
+
plan_dest = handoff_folder / "plan.md"
|
|
262
|
+
success, error = atomic_write(plan_dest, plan_content)
|
|
263
|
+
if success:
|
|
264
|
+
eprint(f"[save_handoff] Copied plan from {plan_path}")
|
|
265
|
+
else:
|
|
266
|
+
eprint(f"[save_handoff] Warning: Failed to copy plan: {error}")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
eprint(f"[save_handoff] Warning: Failed to read plan: {e}")
|
|
269
|
+
|
|
270
|
+
# Write index.md
|
|
271
|
+
index_content = generate_index(frontmatter, sections, git_status, has_plan)
|
|
272
|
+
index_path = handoff_folder / "index.md"
|
|
273
|
+
success, error = atomic_write(index_path, index_content)
|
|
274
|
+
if not success:
|
|
275
|
+
print(f"Error: Failed to write index.md: {error}", file=sys.stderr)
|
|
82
276
|
sys.exit(1)
|
|
83
277
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
278
|
+
# Write section files
|
|
279
|
+
section_mapping = {
|
|
280
|
+
'completed': ('completed-work.md', 'Work Completed'),
|
|
281
|
+
'dead-ends': ('dead-ends.md', 'Dead Ends - Do Not Retry'),
|
|
282
|
+
'decisions': ('decisions.md', 'Key Decisions'),
|
|
283
|
+
'pending': ('pending.md', 'Pending Issues'),
|
|
284
|
+
'next-steps': ('pending.md', None), # Append to pending.md
|
|
285
|
+
'files': ('completed-work.md', None), # Append to completed-work.md
|
|
286
|
+
'context': ('context.md', 'Context for Future Sessions'),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Track which files we've written with their content
|
|
290
|
+
file_contents: Dict[str, list] = {}
|
|
291
|
+
|
|
292
|
+
for section_name, (filename, title) in section_mapping.items():
|
|
293
|
+
section_content = sections.get(section_name, '')
|
|
294
|
+
if not section_content:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
if title is None:
|
|
298
|
+
# Append mode - add to existing content
|
|
299
|
+
if filename not in file_contents:
|
|
300
|
+
file_contents[filename] = []
|
|
301
|
+
file_contents[filename].append(section_content)
|
|
302
|
+
else:
|
|
303
|
+
# Write mode - set as main content with title
|
|
304
|
+
if filename not in file_contents:
|
|
305
|
+
file_contents[filename] = [f"# {title}", "", section_content]
|
|
306
|
+
else:
|
|
307
|
+
# Insert title at beginning if not present
|
|
308
|
+
file_contents[filename] = [f"# {title}", ""] + file_contents[filename] + ["", section_content]
|
|
309
|
+
|
|
310
|
+
# Write all accumulated content
|
|
311
|
+
for filename, content_parts in file_contents.items():
|
|
312
|
+
file_path = handoff_folder / filename
|
|
313
|
+
full_content = '\n'.join(content_parts) + '\n'
|
|
314
|
+
success, error = atomic_write(file_path, full_content)
|
|
315
|
+
if not success:
|
|
316
|
+
eprint(f"[save_handoff] Warning: Failed to write {filename}: {error}")
|
|
317
|
+
|
|
318
|
+
# Ensure all expected files exist (even if empty)
|
|
319
|
+
expected_files = ['completed-work.md', 'dead-ends.md', 'decisions.md', 'pending.md', 'context.md']
|
|
320
|
+
titles = {
|
|
321
|
+
'completed-work.md': 'Work Completed',
|
|
322
|
+
'dead-ends.md': 'Dead Ends - Do Not Retry',
|
|
323
|
+
'decisions.md': 'Key Decisions',
|
|
324
|
+
'pending.md': 'Pending Issues & Next Steps',
|
|
325
|
+
'context.md': 'Context for Future Sessions',
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for filename in expected_files:
|
|
329
|
+
file_path = handoff_folder / filename
|
|
330
|
+
if not file_path.exists():
|
|
331
|
+
write_section_file(handoff_folder, filename, titles[filename], "")
|
|
332
|
+
|
|
333
|
+
# Output success message (ASCII-safe for Windows)
|
|
334
|
+
print(f"[OK] Created handoff folder: {handoff_folder}")
|
|
335
|
+
print(f" - index.md (entry point with navigation)")
|
|
336
|
+
|
|
337
|
+
files_created = [f.name for f in handoff_folder.iterdir() if f.is_file() and f.name != 'index.md']
|
|
338
|
+
print(f" - {', '.join(sorted(files_created))}")
|
|
339
|
+
|
|
94
340
|
print()
|
|
95
|
-
print("
|
|
341
|
+
print("Handoff document saved. Use this folder for context in the next session.")
|
|
96
342
|
|
|
97
343
|
|
|
98
344
|
if __name__ == "__main__":
|
|
@@ -52,7 +52,7 @@ If no active context is found, inform the user and stop - handoffs require an ac
|
|
|
52
52
|
|
|
53
53
|
### Step 3: Generate Document
|
|
54
54
|
|
|
55
|
-
Use this template:
|
|
55
|
+
Use this template. The `<!-- SECTION: name -->` markers are required for the save script to parse sections into sharded files.
|
|
56
56
|
|
|
57
57
|
```markdown
|
|
58
58
|
---
|
|
@@ -66,34 +66,40 @@ plan_document: {path to plan if provided, or "none"}
|
|
|
66
66
|
|
|
67
67
|
# Session Handoff — {Date}
|
|
68
68
|
|
|
69
|
+
<!-- SECTION: summary -->
|
|
69
70
|
## Summary
|
|
70
71
|
{2-3 sentences: what's different now vs. session start}
|
|
71
72
|
|
|
73
|
+
<!-- SECTION: completed -->
|
|
72
74
|
## Work Completed
|
|
73
75
|
{Grouped by category if multiple areas. Specific file:function references.}
|
|
74
76
|
|
|
77
|
+
<!-- SECTION: dead-ends -->
|
|
75
78
|
## Dead Ends — Do Not Retry
|
|
76
|
-
{Approaches that were tried and failed. Critical for avoiding wasted effort in future sessions.}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
| Approach Tried | Why It Failed | Time Spent |
|
|
80
|
-
|----------------|---------------|------------|
|
|
81
|
-
| {What was attempted} | {Specific reason: error, incompatibility, performance, etc.} | {Rough estimate} |
|
|
80
|
+
These approaches were attempted and failed. Do not retry without addressing the root cause.
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
| Approach | Why It Failed | Time Spent | Alternative |
|
|
83
|
+
|----------|---------------|------------|-------------|
|
|
84
|
+
| {What was attempted} | {Specific reason} | {Rough estimate} | {What to try instead} |
|
|
84
85
|
|
|
86
|
+
<!-- SECTION: decisions -->
|
|
85
87
|
## Key Decisions
|
|
86
88
|
{Technical choices with rationale. Format: **Decision**: Rationale. Trade-off: X.}
|
|
87
89
|
|
|
90
|
+
<!-- SECTION: pending -->
|
|
88
91
|
## Pending Issues
|
|
89
92
|
- [ ] {Issue} — {severity: HIGH/MED/LOW} {optional workaround note}
|
|
90
93
|
|
|
94
|
+
<!-- SECTION: next-steps -->
|
|
91
95
|
## Next Steps
|
|
92
96
|
1. {Actionable item with file:line reference if applicable}
|
|
93
97
|
|
|
98
|
+
<!-- SECTION: files -->
|
|
94
99
|
## Files Modified
|
|
95
100
|
{Significant changes only. Skip formatting-only edits.}
|
|
96
101
|
|
|
102
|
+
<!-- SECTION: context -->
|
|
97
103
|
## Context for Future Sessions
|
|
98
104
|
{Non-obvious context: env quirks, stakeholder requirements}
|
|
99
105
|
|
|
@@ -150,11 +156,12 @@ EOF
|
|
|
150
156
|
```
|
|
151
157
|
|
|
152
158
|
This script:
|
|
153
|
-
1.
|
|
154
|
-
2.
|
|
155
|
-
3.
|
|
159
|
+
1. Creates a folder at `_output/contexts/{context_id}/handoffs/{YYYY-MM-DD-HHMM}/`
|
|
160
|
+
2. Parses sections and writes sharded files (index.md, completed-work.md, dead-ends.md, etc.)
|
|
161
|
+
3. Copies the current plan (if any) to plan.md
|
|
162
|
+
4. Records the event in the context's event log (informational only)
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
Use the handoff folder for context in the next session.
|
|
158
165
|
|
|
159
166
|
## Dead Ends Section Guidelines
|
|
160
167
|
|
|
@@ -185,10 +192,14 @@ This section is critical for preventing context rot across sessions. Be specific
|
|
|
185
192
|
After creating file, output:
|
|
186
193
|
|
|
187
194
|
```
|
|
188
|
-
✓ Created _output/contexts/{context_id}/handoffs/
|
|
195
|
+
✓ Created handoff folder: _output/contexts/{context_id}/handoffs/{YYYY-MM-DD-HHMM}/
|
|
196
|
+
- index.md (entry point with navigation)
|
|
197
|
+
- completed-work.md, dead-ends.md, decisions.md, pending.md, context.md
|
|
198
|
+
- plan.md (copy of current plan, if any)
|
|
189
199
|
|
|
190
200
|
To continue next session:
|
|
191
|
-
|
|
201
|
+
The index.md will be automatically suggested when you start a new session.
|
|
202
|
+
Read dead-ends.md first to avoid repeating failed approaches.
|
|
192
203
|
|
|
193
204
|
⚠️ {N} dead ends documented — avoid re-attempting these approaches
|
|
194
205
|
```
|
|
@@ -203,10 +214,13 @@ If plan was updated:
|
|
|
203
214
|
|
|
204
215
|
## Success Criteria
|
|
205
216
|
|
|
206
|
-
- [ ] Handoff
|
|
207
|
-
- [ ]
|
|
217
|
+
- [ ] Handoff folder created at `handoffs/{YYYY-MM-DD-HHMM}/`
|
|
218
|
+
- [ ] index.md contains summary and navigation table
|
|
219
|
+
- [ ] All section files created (completed-work.md, dead-ends.md, etc.)
|
|
220
|
+
- [ ] Dead ends use structured table format for quick scanning
|
|
221
|
+
- [ ] plan.md copied from context if plan exists
|
|
208
222
|
- [ ] Next steps are actionable with file references
|
|
209
|
-
- [ ] Git status
|
|
223
|
+
- [ ] Git status included in index.md
|
|
210
224
|
- [ ] If plan provided: checkboxes updated to reflect completion status
|
|
211
225
|
- [ ] If plan provided: Session Progress Log appended
|
|
212
226
|
- [ ] Context state updated to indicate handoff pending
|
|
@@ -28,6 +28,7 @@ Output: _output/cc-native/plans/{YYYY-MM-DD}/{slug}/reviews/
|
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
import json
|
|
31
|
+
import os
|
|
31
32
|
import sys
|
|
32
33
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
33
34
|
from datetime import datetime
|
|
@@ -43,6 +44,9 @@ try:
|
|
|
43
44
|
_shared = Path(__file__).parent.parent.parent / "_shared"
|
|
44
45
|
sys.path.insert(0, str(_shared))
|
|
45
46
|
|
|
47
|
+
# Import subprocess utilities
|
|
48
|
+
from lib.base.subprocess_utils import is_internal_call
|
|
49
|
+
|
|
46
50
|
from utils import (
|
|
47
51
|
DEFAULT_DISPLAY,
|
|
48
52
|
DEFAULT_SANITIZATION,
|
|
@@ -79,7 +83,7 @@ try:
|
|
|
79
83
|
get_all_in_flight_contexts,
|
|
80
84
|
get_all_contexts,
|
|
81
85
|
)
|
|
82
|
-
from lib.base.constants import get_context_reviews_dir
|
|
86
|
+
from lib.base.constants import get_context_reviews_dir, get_review_folder_path
|
|
83
87
|
except ImportError as e:
|
|
84
88
|
print(f"[cc-native-plan-review] Failed to import lib: {e}", file=sys.stderr)
|
|
85
89
|
sys.exit(0) # Non-blocking failure
|
|
@@ -133,9 +137,10 @@ def get_active_context_for_review(session_id: str, project_root: Path) -> Option
|
|
|
133
137
|
|
|
134
138
|
Strategy:
|
|
135
139
|
1. Find context by session_id
|
|
136
|
-
2. Fallback: Single in
|
|
137
|
-
3.
|
|
138
|
-
|
|
140
|
+
2. Fallback: Single context in 'planning' mode
|
|
141
|
+
3. Return None if multiple planning contexts or no planning contexts found
|
|
142
|
+
|
|
143
|
+
Only triggers for contexts in 'planning' mode, not 'handoff_pending' or other modes.
|
|
139
144
|
|
|
140
145
|
Args:
|
|
141
146
|
session_id: Current session ID
|
|
@@ -150,23 +155,21 @@ def get_active_context_for_review(session_id: str, project_root: Path) -> Option
|
|
|
150
155
|
eprint(f"[cc-native-plan-review] Found context by session_id: {context.id}")
|
|
151
156
|
return context
|
|
152
157
|
|
|
153
|
-
# Strategy 2: Single
|
|
158
|
+
# Strategy 2: Single planning context (only planning mode)
|
|
154
159
|
in_flight = get_all_in_flight_contexts(project_root)
|
|
155
|
-
if len(in_flight) == 1:
|
|
156
|
-
eprint(f"[cc-native-plan-review] Found single in-flight context: {in_flight[0].id}")
|
|
157
|
-
return in_flight[0]
|
|
158
|
-
|
|
159
|
-
# Strategy 3: Single planning context
|
|
160
160
|
planning_contexts = [c for c in in_flight if c.in_flight and c.in_flight.mode == "planning"]
|
|
161
161
|
if len(planning_contexts) == 1:
|
|
162
162
|
eprint(f"[cc-native-plan-review] Found single planning context: {planning_contexts[0].id}")
|
|
163
163
|
return planning_contexts[0]
|
|
164
164
|
|
|
165
|
-
# Multiple or no contexts found
|
|
166
|
-
if len(
|
|
167
|
-
eprint(f"[cc-native-plan-review] Multiple
|
|
165
|
+
# Multiple or no planning contexts found
|
|
166
|
+
if len(planning_contexts) > 1:
|
|
167
|
+
eprint(f"[cc-native-plan-review] Multiple planning contexts ({len(planning_contexts)}), cannot determine which to use")
|
|
168
|
+
elif len(in_flight) > 0:
|
|
169
|
+
modes = [c.in_flight.mode if c.in_flight else "none" for c in in_flight]
|
|
170
|
+
eprint(f"[cc-native-plan-review] Found {len(in_flight)} in-flight context(s) with modes {modes}, but none in 'planning' mode")
|
|
168
171
|
else:
|
|
169
|
-
eprint("[cc-native-plan-review] No in-flight contexts found
|
|
172
|
+
eprint("[cc-native-plan-review] No in-flight contexts found")
|
|
170
173
|
return None
|
|
171
174
|
|
|
172
175
|
|
|
@@ -423,6 +426,11 @@ def load_agent_library(proj_dir: Path, settings: Optional[Dict[str, Any]] = None
|
|
|
423
426
|
def main() -> int:
|
|
424
427
|
eprint("[cc-native-plan-review] Unified hook started (PreToolUse)")
|
|
425
428
|
|
|
429
|
+
# Skip if internal subprocess call (orchestrator, agents)
|
|
430
|
+
if is_internal_call():
|
|
431
|
+
eprint("[cc-native-plan-review] Skipping: internal subprocess call")
|
|
432
|
+
return 0
|
|
433
|
+
|
|
426
434
|
try:
|
|
427
435
|
payload = json.load(sys.stdin)
|
|
428
436
|
except json.JSONDecodeError as e:
|
|
@@ -582,11 +590,8 @@ def main() -> int:
|
|
|
582
590
|
# Use orchestrator result from phase 1
|
|
583
591
|
detected_complexity = orch_result.complexity
|
|
584
592
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
else:
|
|
588
|
-
selected_names = set(orch_result.selected_agents)
|
|
589
|
-
selected_agents = [a for a in enabled_agents if a.name in selected_names]
|
|
593
|
+
selected_names = set(orch_result.selected_agents)
|
|
594
|
+
selected_agents = [a for a in enabled_agents if a.name in selected_names]
|
|
590
595
|
|
|
591
596
|
if not selected_agents and selected_names:
|
|
592
597
|
eprint(f"[cc-native-plan-review] Warning: orchestrator selected unknown agents: {selected_names}")
|
|
@@ -659,9 +664,20 @@ def main() -> int:
|
|
|
659
664
|
display_settings = {**plan_settings.get("display", {}), **agent_settings.get("display", {})}
|
|
660
665
|
combined_settings = {"display": display_settings}
|
|
661
666
|
|
|
667
|
+
# Get current iteration number for folder naming
|
|
668
|
+
current_iteration = 1
|
|
669
|
+
if iteration_state:
|
|
670
|
+
current_iteration = iteration_state.get("current", 1)
|
|
671
|
+
|
|
672
|
+
# Create review folder with datetime and iteration in name
|
|
673
|
+
review_folder = get_review_folder_path(active_context.id, current_iteration, base)
|
|
674
|
+
review_folder.mkdir(parents=True, exist_ok=True)
|
|
675
|
+
eprint(f"[cc-native-plan-review] Created review folder: {review_folder}")
|
|
676
|
+
|
|
662
677
|
review_file = write_combined_artifacts(
|
|
663
678
|
base, plan, combined_result, payload, combined_settings,
|
|
664
|
-
|
|
679
|
+
review_folder=review_folder,
|
|
680
|
+
iteration=current_iteration,
|
|
665
681
|
)
|
|
666
682
|
eprint(f"[cc-native-plan-review] Saved review: {review_file}")
|
|
667
683
|
|
|
@@ -704,6 +720,11 @@ def main() -> int:
|
|
|
704
720
|
else:
|
|
705
721
|
# Final iteration - increment current and save state
|
|
706
722
|
iteration_state["current"] = iteration_state.get("current", 1) + 1
|
|
723
|
+
# Also increment max by 1 to allow another review cycle if the user rejects
|
|
724
|
+
# the plan and requests changes. Without this, once iterations are exhausted,
|
|
725
|
+
# the hook would skip review entirely (line ~498) even if the user sent the
|
|
726
|
+
# planner back to revise. This ensures rejected plans can always be re-reviewed.
|
|
727
|
+
iteration_state["max"] = iteration_state.get("max", 1) + 1
|
|
707
728
|
save_iteration_state(reviews_dir, iteration_state)
|
|
708
729
|
|
|
709
730
|
# Build output with correct Claude Code hook format
|
|
@@ -18,6 +18,11 @@ sys.path.insert(0, str(_lib_dir))
|
|
|
18
18
|
from utils import OrchestratorResult, eprint, parse_json_maybe
|
|
19
19
|
from reviewers.base import AgentConfig, OrchestratorConfig
|
|
20
20
|
|
|
21
|
+
# Import shared subprocess utilities
|
|
22
|
+
_shared_lib = Path(__file__).resolve().parent.parent.parent / "_shared" / "lib" / "base"
|
|
23
|
+
sys.path.insert(0, str(_shared_lib))
|
|
24
|
+
from subprocess_utils import get_internal_subprocess_env
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
# ---------------------------
|
|
23
28
|
# Constants
|
|
@@ -199,6 +204,9 @@ PLAN:
|
|
|
199
204
|
|
|
200
205
|
eprint(f"[orchestrator] Running with model: {config.model}, timeout: {config.timeout}s")
|
|
201
206
|
|
|
207
|
+
# Get environment for internal subprocess (bypasses hooks)
|
|
208
|
+
env = get_internal_subprocess_env()
|
|
209
|
+
|
|
202
210
|
try:
|
|
203
211
|
p = subprocess.run(
|
|
204
212
|
cmd_args,
|
|
@@ -208,6 +216,7 @@ PLAN:
|
|
|
208
216
|
timeout=config.timeout,
|
|
209
217
|
encoding="utf-8",
|
|
210
218
|
errors="replace",
|
|
219
|
+
env=env,
|
|
211
220
|
)
|
|
212
221
|
except subprocess.TimeoutExpired:
|
|
213
222
|
eprint(f"[orchestrator] TIMEOUT after {config.timeout}s, falling back to medium complexity")
|
|
@@ -18,6 +18,11 @@ sys.path.insert(0, str(_lib_dir))
|
|
|
18
18
|
from utils import ReviewerResult, eprint, parse_json_maybe, coerce_to_review
|
|
19
19
|
from .base import AgentConfig, AGENT_REVIEW_PROMPT_PREFIX
|
|
20
20
|
|
|
21
|
+
# Import shared subprocess utilities
|
|
22
|
+
_shared_lib = Path(__file__).resolve().parent.parent.parent.parent / "_shared" / "lib" / "base"
|
|
23
|
+
sys.path.insert(0, str(_shared_lib))
|
|
24
|
+
from subprocess_utils import get_internal_subprocess_env
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
def _parse_claude_output(raw: str) -> Optional[Dict[str, Any]]:
|
|
23
28
|
"""Parse Claude CLI JSON output, handling various formats.
|
|
@@ -125,6 +130,9 @@ PLAN:
|
|
|
125
130
|
|
|
126
131
|
eprint(f"[{agent.name}] Running with model: {agent.model}, timeout: {timeout}s, max-turns: {max_turns}")
|
|
127
132
|
|
|
133
|
+
# Get environment for internal subprocess (bypasses hooks)
|
|
134
|
+
env = get_internal_subprocess_env()
|
|
135
|
+
|
|
128
136
|
try:
|
|
129
137
|
p = subprocess.run(
|
|
130
138
|
cmd_args,
|
|
@@ -134,6 +142,7 @@ PLAN:
|
|
|
134
142
|
timeout=timeout,
|
|
135
143
|
encoding="utf-8",
|
|
136
144
|
errors="replace",
|
|
145
|
+
env=env,
|
|
137
146
|
)
|
|
138
147
|
except subprocess.TimeoutExpired:
|
|
139
148
|
eprint(f"[{agent.name}] TIMEOUT after {timeout}s")
|