aiwcli 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1248 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +16 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +19 -0
- package/dist/commands/branch.d.ts +45 -0
- package/dist/commands/branch.js +488 -0
- package/dist/commands/clean.d.ts +34 -0
- package/dist/commands/clean.js +186 -0
- package/dist/commands/clear.d.ts +51 -0
- package/dist/commands/clear.js +835 -0
- package/dist/commands/init/index.d.ts +107 -0
- package/dist/commands/init/index.js +565 -0
- package/dist/commands/launch.d.ts +21 -0
- package/dist/commands/launch.js +108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/base-command.d.ts +114 -0
- package/dist/lib/base-command.js +153 -0
- package/dist/lib/bmad-installer.d.ts +38 -0
- package/dist/lib/bmad-installer.js +145 -0
- package/dist/lib/claude-settings-types.d.ts +102 -0
- package/dist/lib/claude-settings-types.js +5 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/debug.d.ts +39 -0
- package/dist/lib/debug.js +74 -0
- package/dist/lib/env-compat.d.ts +26 -0
- package/dist/lib/env-compat.js +35 -0
- package/dist/lib/errors.d.ts +126 -0
- package/dist/lib/errors.js +145 -0
- package/dist/lib/generic-merge.d.ts +74 -0
- package/dist/lib/generic-merge.js +105 -0
- package/dist/lib/git/branch.d.ts +67 -0
- package/dist/lib/git/branch.js +155 -0
- package/dist/lib/git/index.d.ts +11 -0
- package/dist/lib/git/index.js +13 -0
- package/dist/lib/git/safety-checks.d.ts +44 -0
- package/dist/lib/git/safety-checks.js +102 -0
- package/dist/lib/git/types.d.ts +31 -0
- package/dist/lib/git/types.js +6 -0
- package/dist/lib/git/worktree.d.ts +67 -0
- package/dist/lib/git/worktree.js +220 -0
- package/dist/lib/gitignore-manager.d.ts +10 -0
- package/dist/lib/gitignore-manager.js +60 -0
- package/dist/lib/hooks-merger.d.ts +28 -0
- package/dist/lib/hooks-merger.js +94 -0
- package/dist/lib/ide-path-resolver.d.ts +102 -0
- package/dist/lib/ide-path-resolver.js +129 -0
- package/dist/lib/index.d.ts +13 -0
- package/dist/lib/index.js +22 -0
- package/dist/lib/output.d.ts +51 -0
- package/dist/lib/output.js +76 -0
- package/dist/lib/paths.d.ts +66 -0
- package/dist/lib/paths.js +136 -0
- package/dist/lib/quiet.d.ts +12 -0
- package/dist/lib/quiet.js +17 -0
- package/dist/lib/settings-hierarchy.d.ts +42 -0
- package/dist/lib/settings-hierarchy.js +105 -0
- package/dist/lib/spawn.d.ts +105 -0
- package/dist/lib/spawn.js +157 -0
- package/dist/lib/spinner.d.ts +19 -0
- package/dist/lib/spinner.js +34 -0
- package/dist/lib/stdin.d.ts +48 -0
- package/dist/lib/stdin.js +60 -0
- package/dist/lib/template-installer.d.ts +92 -0
- package/dist/lib/template-installer.js +375 -0
- package/dist/lib/template-linter.d.ts +49 -0
- package/dist/lib/template-linter.js +173 -0
- package/dist/lib/template-merger.d.ts +47 -0
- package/dist/lib/template-merger.js +173 -0
- package/dist/lib/template-resolver.d.ts +20 -0
- package/dist/lib/template-resolver.js +60 -0
- package/dist/lib/terminal.d.ts +102 -0
- package/dist/lib/terminal.js +245 -0
- package/dist/lib/tty-detection.d.ts +62 -0
- package/dist/lib/tty-detection.js +83 -0
- package/dist/lib/user-utils.d.ts +5 -0
- package/dist/lib/user-utils.js +23 -0
- package/dist/lib/version.d.ts +99 -0
- package/dist/lib/version.js +144 -0
- package/dist/lib/watch-templates.d.ts +6 -0
- package/dist/lib/watch-templates.js +73 -0
- package/dist/lib/windsurf-hooks-hierarchy.d.ts +30 -0
- package/dist/lib/windsurf-hooks-hierarchy.js +66 -0
- package/dist/lib/windsurf-hooks-merger.d.ts +26 -0
- package/dist/lib/windsurf-hooks-merger.js +53 -0
- package/dist/lib/windsurf-hooks-types.d.ts +33 -0
- package/dist/lib/windsurf-hooks-types.js +5 -0
- package/dist/templates/CLAUDE.md +174 -0
- package/dist/templates/_shared/.claude/commands/handoff.md +14 -0
- package/dist/templates/_shared/.claude/settings.json +61 -0
- package/dist/templates/_shared/.codex/workflows/handoff.md +14 -0
- package/dist/templates/_shared/.windsurf/workflows/handoff.md +14 -0
- package/dist/templates/_shared/hooks/__init__.py +16 -0
- package/dist/templates/_shared/hooks/archive_plan.py +270 -0
- package/dist/templates/_shared/hooks/context_enforcer.py +621 -0
- package/dist/templates/_shared/hooks/context_monitor.py +322 -0
- package/dist/templates/_shared/hooks/file-suggestion.py +188 -0
- package/dist/templates/_shared/hooks/task_create_capture.py +194 -0
- package/dist/templates/_shared/hooks/task_update_capture.py +254 -0
- package/dist/templates/_shared/hooks/user_prompt_submit.py +157 -0
- package/dist/templates/_shared/lib/__init__.py +1 -0
- package/dist/templates/_shared/lib/base/__init__.py +49 -0
- package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/atomic_write.py +180 -0
- package/dist/templates/_shared/lib/base/constants.py +299 -0
- package/dist/templates/_shared/lib/base/inference.py +189 -0
- package/dist/templates/_shared/lib/base/utils.py +216 -0
- package/dist/templates/_shared/lib/context/__init__.py +119 -0
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/cache.py +446 -0
- package/dist/templates/_shared/lib/context/context_manager.py +1171 -0
- package/dist/templates/_shared/lib/context/discovery.py +486 -0
- package/dist/templates/_shared/lib/context/event_log.py +308 -0
- package/dist/templates/_shared/lib/context/plan_archive.py +247 -0
- package/dist/templates/_shared/lib/context/task_sync.py +367 -0
- package/dist/templates/_shared/lib/handoff/__init__.py +22 -0
- package/dist/templates/_shared/lib/handoff/document_generator.py +307 -0
- package/dist/templates/_shared/lib/templates/README.md +215 -0
- package/dist/templates/_shared/lib/templates/__init__.py +40 -0
- package/dist/templates/_shared/lib/templates/formatters.py +147 -0
- package/dist/templates/_shared/lib/templates/plan_context.py +119 -0
- package/dist/templates/_shared/scripts/save_handoff.py +99 -0
- package/dist/templates/_shared/workflows/handoff.md +212 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ACCESSIBILITY-TESTER.md +80 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ARCHITECT-REVIEWER.md +75 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ASSUMPTION-CHAIN-TRACER.md +239 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CLARITY-AUDITOR.md +109 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CODE-REVIEWER.md +71 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/COMPLETENESS-CHECKER.md +104 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CONTEXT-EXTRACTOR.md +93 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/DEVILS-ADVOCATE.md +223 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/DOCUMENTATION-REVIEWER.md +73 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/FEASIBILITY-ANALYST.md +93 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/FRESH-PERSPECTIVE.md +103 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/HANDOFF-READINESS.md +145 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/HIDDEN-COMPLEXITY-DETECTOR.md +248 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/INCENTIVE-MAPPER.md +235 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PENETRATION-TESTER.md +80 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PERFORMANCE-ENGINEER.md +76 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PLAN-ORCHESTRATOR.md +141 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PRECEDENT-FINDER.md +240 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/REVERSIBILITY-ANALYST.md +211 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/RISK-ASSESSOR.md +101 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SECOND-ORDER-ANALYST.md +197 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SIMPLICITY-GUARDIAN.md +97 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SKEPTIC.md +349 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/STAKEHOLDER-ADVOCATE.md +106 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/TRADE-OFF-ILLUMINATOR.md +205 -0
- package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +8 -0
- package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -0
- package/dist/templates/cc-native/.claude/settings.json +119 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -0
- package/dist/templates/cc-native/CC-NATIVE-README.md +192 -0
- package/dist/templates/cc-native/MIGRATION.md +86 -0
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +331 -0
- package/dist/templates/cc-native/_cc-native/docs/PERMISSION_REQUEST_VERIFICATION.md +147 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +150 -0
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +746 -0
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +339 -0
- package/dist/templates/cc-native/_cc-native/lib/__init__.py +57 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/async_archive.py +68 -0
- package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +98 -0
- package/dist/templates/cc-native/_cc-native/lib/constants.py +45 -0
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +273 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +28 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +164 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +89 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +119 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +103 -0
- package/dist/templates/cc-native/_cc-native/lib/state.py +251 -0
- package/dist/templates/cc-native/_cc-native/lib/utils.py +830 -0
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +76 -0
- package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +151 -0
- package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +134 -0
- package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -0
- package/dist/types/exit-codes.d.ts +11 -0
- package/dist/types/exit-codes.js +10 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +7 -0
- package/oclif.manifest.json +405 -0
- package/package.json +109 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook - captures TaskUpdate operations for persistence.
|
|
3
|
+
|
|
4
|
+
This hook runs after Claude uses the TaskUpdate tool and automatically
|
|
5
|
+
records the appropriate event in the context's events.jsonl based on the
|
|
6
|
+
status change.
|
|
7
|
+
|
|
8
|
+
Status mappings:
|
|
9
|
+
- status: "in_progress" -> record_task_started()
|
|
10
|
+
- status: "completed" -> record_task_completed()
|
|
11
|
+
- blockedBy added -> record_task_blocked()
|
|
12
|
+
|
|
13
|
+
Hook input (from Claude Code):
|
|
14
|
+
{
|
|
15
|
+
"hook_event_name": "PostToolUse",
|
|
16
|
+
"tool_name": "TaskUpdate",
|
|
17
|
+
"tool_input": {
|
|
18
|
+
"taskId": "1",
|
|
19
|
+
"status": "completed",
|
|
20
|
+
"metadata": {"evidence": "...", "work_summary": "...", ...},
|
|
21
|
+
"addBlockedBy": ["2"],
|
|
22
|
+
...
|
|
23
|
+
},
|
|
24
|
+
"tool_response": {...},
|
|
25
|
+
"session_id": "abc123",
|
|
26
|
+
"cwd": "/path/to/project"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Hook output:
|
|
30
|
+
- Silent on success (no stdout output)
|
|
31
|
+
- Logs to stderr for debugging
|
|
32
|
+
"""
|
|
33
|
+
import json
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Optional, Dict, Any
|
|
37
|
+
|
|
38
|
+
# Add parent directories to path for imports
|
|
39
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
40
|
+
SHARED_LIB = SCRIPT_DIR.parent / "lib"
|
|
41
|
+
sys.path.insert(0, str(SHARED_LIB.parent))
|
|
42
|
+
|
|
43
|
+
from lib.context.task_sync import (
|
|
44
|
+
record_task_started,
|
|
45
|
+
record_task_completed,
|
|
46
|
+
record_task_blocked,
|
|
47
|
+
)
|
|
48
|
+
from lib.context.context_manager import get_all_contexts, get_context_by_session_id
|
|
49
|
+
from lib.base.utils import eprint, project_dir
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_context_id(
|
|
53
|
+
tool_input: Dict[str, Any],
|
|
54
|
+
project_root: Path,
|
|
55
|
+
session_id: Optional[str] = None
|
|
56
|
+
) -> Optional[str]:
|
|
57
|
+
"""
|
|
58
|
+
Extract context ID from tool input metadata, session, or active contexts.
|
|
59
|
+
|
|
60
|
+
Priority:
|
|
61
|
+
1. metadata.context field
|
|
62
|
+
2. Session ID lookup (session bound to context)
|
|
63
|
+
3. Single active context
|
|
64
|
+
4. None (skips persistence)
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
tool_input: Tool input from TaskUpdate
|
|
68
|
+
project_root: Project root directory
|
|
69
|
+
session_id: Session ID from hook payload
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Context ID or None if cannot determine
|
|
73
|
+
"""
|
|
74
|
+
# Check metadata.context field
|
|
75
|
+
metadata = tool_input.get("metadata", {})
|
|
76
|
+
if isinstance(metadata, dict):
|
|
77
|
+
context = metadata.get("context")
|
|
78
|
+
if context:
|
|
79
|
+
return context
|
|
80
|
+
|
|
81
|
+
# Check session ID - session may be bound to a context
|
|
82
|
+
if session_id:
|
|
83
|
+
try:
|
|
84
|
+
session_context = get_context_by_session_id(session_id, project_root)
|
|
85
|
+
if session_context:
|
|
86
|
+
eprint(f"[task_update_capture] Found context via session_id: {session_context.id}")
|
|
87
|
+
return session_context.id
|
|
88
|
+
except Exception as e:
|
|
89
|
+
eprint(f"[task_update_capture] Failed to lookup context by session: {e}")
|
|
90
|
+
|
|
91
|
+
# Check for single active context
|
|
92
|
+
try:
|
|
93
|
+
contexts = get_all_contexts(status="active", project_root=project_root)
|
|
94
|
+
if len(contexts) == 1:
|
|
95
|
+
return contexts[0].id
|
|
96
|
+
except Exception as e:
|
|
97
|
+
eprint(f"[task_update_capture] Failed to get active contexts: {e}")
|
|
98
|
+
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_persistent_task_id(
|
|
103
|
+
claude_task_id: str,
|
|
104
|
+
tool_input: Dict[str, Any]
|
|
105
|
+
) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Convert Claude's ephemeral task ID to persistent task ID.
|
|
108
|
+
|
|
109
|
+
If metadata.persistent_id exists, use that.
|
|
110
|
+
Otherwise, assume format "aiw-{claude_task_id}".
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
claude_task_id: Task ID from Claude (e.g., "1", "2")
|
|
114
|
+
tool_input: Tool input dict
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Persistent task ID (e.g., "aiw-1")
|
|
118
|
+
"""
|
|
119
|
+
metadata = tool_input.get("metadata", {})
|
|
120
|
+
if isinstance(metadata, dict):
|
|
121
|
+
persistent_id = metadata.get("persistent_id")
|
|
122
|
+
if persistent_id:
|
|
123
|
+
return persistent_id
|
|
124
|
+
|
|
125
|
+
# Default: aiw-{id}
|
|
126
|
+
return f"aiw-{claude_task_id}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> int:
|
|
130
|
+
"""
|
|
131
|
+
Main hook entry point.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
0 on success, non-zero on failure (but hook is non-blocking)
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
# Parse hook input
|
|
138
|
+
payload = json.load(sys.stdin)
|
|
139
|
+
|
|
140
|
+
# Validate hook type
|
|
141
|
+
if payload.get("hook_event_name") != "PostToolUse":
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
# Validate tool name
|
|
145
|
+
if payload.get("tool_name") != "TaskUpdate":
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
# Extract tool input
|
|
149
|
+
tool_input = payload.get("tool_input", {})
|
|
150
|
+
if not isinstance(tool_input, dict):
|
|
151
|
+
eprint("[task_update_capture] Invalid tool_input: not a dict")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
# Check for skip_persistence flag (used during hydration to avoid duplicates)
|
|
155
|
+
metadata = tool_input.get("metadata", {})
|
|
156
|
+
if isinstance(metadata, dict) and metadata.get("skip_persistence"):
|
|
157
|
+
eprint("[task_update_capture] Skipping persistence (hydration mode)")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
# Get project root and session ID
|
|
161
|
+
project_root = project_dir(payload)
|
|
162
|
+
session_id = payload.get("session_id")
|
|
163
|
+
|
|
164
|
+
# Extract context ID
|
|
165
|
+
context_id = extract_context_id(tool_input, project_root, session_id)
|
|
166
|
+
if not context_id:
|
|
167
|
+
eprint("[task_update_capture] No context available - skipping persistence")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
# Extract task ID
|
|
171
|
+
claude_task_id = tool_input.get("taskId")
|
|
172
|
+
if not claude_task_id:
|
|
173
|
+
eprint("[task_update_capture] Missing required field: taskId")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
# Get persistent task ID
|
|
177
|
+
persistent_task_id = get_persistent_task_id(claude_task_id, tool_input)
|
|
178
|
+
|
|
179
|
+
# Check for status change
|
|
180
|
+
status = tool_input.get("status")
|
|
181
|
+
metadata = tool_input.get("metadata", {})
|
|
182
|
+
add_blocked_by = tool_input.get("addBlockedBy", [])
|
|
183
|
+
|
|
184
|
+
# Handle different update types
|
|
185
|
+
events_recorded = []
|
|
186
|
+
|
|
187
|
+
# Status: in_progress
|
|
188
|
+
if status == "in_progress":
|
|
189
|
+
success = record_task_started(
|
|
190
|
+
context_id=context_id,
|
|
191
|
+
task_id=persistent_task_id,
|
|
192
|
+
project_root=project_root
|
|
193
|
+
)
|
|
194
|
+
if success:
|
|
195
|
+
events_recorded.append("task_started")
|
|
196
|
+
|
|
197
|
+
# Status: completed
|
|
198
|
+
elif status == "completed":
|
|
199
|
+
# Extract rich completion context from metadata
|
|
200
|
+
if isinstance(metadata, dict):
|
|
201
|
+
evidence = metadata.get("evidence", "Task marked completed")
|
|
202
|
+
work_summary = metadata.get("work_summary", "")
|
|
203
|
+
files_changed = metadata.get("files_changed", [])
|
|
204
|
+
commit_ref = metadata.get("commit_ref", "")
|
|
205
|
+
else:
|
|
206
|
+
evidence = "Task marked completed"
|
|
207
|
+
work_summary = ""
|
|
208
|
+
files_changed = []
|
|
209
|
+
commit_ref = ""
|
|
210
|
+
|
|
211
|
+
success = record_task_completed(
|
|
212
|
+
context_id=context_id,
|
|
213
|
+
task_id=persistent_task_id,
|
|
214
|
+
evidence=evidence,
|
|
215
|
+
work_summary=work_summary,
|
|
216
|
+
files_changed=files_changed if isinstance(files_changed, list) else [],
|
|
217
|
+
commit_ref=commit_ref,
|
|
218
|
+
project_root=project_root
|
|
219
|
+
)
|
|
220
|
+
if success:
|
|
221
|
+
events_recorded.append("task_completed")
|
|
222
|
+
|
|
223
|
+
# Blocked by tasks
|
|
224
|
+
if add_blocked_by and isinstance(add_blocked_by, list) and len(add_blocked_by) > 0:
|
|
225
|
+
blocked_reason = f"Blocked by tasks: {', '.join(add_blocked_by)}"
|
|
226
|
+
success = record_task_blocked(
|
|
227
|
+
context_id=context_id,
|
|
228
|
+
task_id=persistent_task_id,
|
|
229
|
+
reason=blocked_reason,
|
|
230
|
+
project_root=project_root
|
|
231
|
+
)
|
|
232
|
+
if success:
|
|
233
|
+
events_recorded.append("task_blocked")
|
|
234
|
+
|
|
235
|
+
if events_recorded:
|
|
236
|
+
eprint(f"[task_update_capture] Recorded {', '.join(events_recorded)} for {persistent_task_id} in {context_id}")
|
|
237
|
+
else:
|
|
238
|
+
eprint(f"[task_update_capture] No relevant status changes detected for {persistent_task_id}")
|
|
239
|
+
|
|
240
|
+
# Silent success (no stdout output)
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
except json.JSONDecodeError as e:
|
|
244
|
+
eprint(f"[task_update_capture] JSON decode error: {e}")
|
|
245
|
+
return 0 # Non-blocking
|
|
246
|
+
except Exception as e:
|
|
247
|
+
eprint(f"[task_update_capture] Unexpected error: {e}")
|
|
248
|
+
import traceback
|
|
249
|
+
eprint(traceback.format_exc())
|
|
250
|
+
return 0 # Non-blocking
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified UserPromptSubmit hook entry point.
|
|
3
|
+
|
|
4
|
+
This hook runs on every UserPromptSubmit and handles:
|
|
5
|
+
- Context enforcement - ensures all work happens in a tracked context
|
|
6
|
+
|
|
7
|
+
Note: Context monitoring (handoff warnings) is handled separately by
|
|
8
|
+
context_monitor.py on PostToolUse events, which fires during Claude's
|
|
9
|
+
work rather than waiting for user input.
|
|
10
|
+
|
|
11
|
+
Hook input (from Claude Code):
|
|
12
|
+
{
|
|
13
|
+
"hook_type": "UserPromptSubmit",
|
|
14
|
+
"prompt": "user's message text",
|
|
15
|
+
"session_id": "abc123",
|
|
16
|
+
...
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Hook output:
|
|
20
|
+
- Prints system reminders to stdout for context enforcement
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import List
|
|
26
|
+
|
|
27
|
+
# Add parent directories to path for imports
|
|
28
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
29
|
+
SHARED_LIB = SCRIPT_DIR.parent / "lib"
|
|
30
|
+
sys.path.insert(0, str(SHARED_LIB.parent))
|
|
31
|
+
|
|
32
|
+
from lib.base.utils import eprint, project_dir
|
|
33
|
+
from lib.context.context_manager import (
|
|
34
|
+
update_context_session_id,
|
|
35
|
+
update_plan_status,
|
|
36
|
+
clear_handoff_status,
|
|
37
|
+
get_context,
|
|
38
|
+
get_context_by_session_id,
|
|
39
|
+
)
|
|
40
|
+
from lib.context.task_sync import generate_hydration_instructions
|
|
41
|
+
|
|
42
|
+
# Import the enforcement module
|
|
43
|
+
from hooks.context_enforcer import determine_context, BlockRequest
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _update_in_flight_status(context_id: str, hook_input: dict, project_root: Path) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Update context in-flight status based on permission mode.
|
|
49
|
+
|
|
50
|
+
- If handoff_pending: clear it (handoff has been consumed by this session)
|
|
51
|
+
- If permission_mode == "plan": set to "planning"
|
|
52
|
+
- If permission_mode in ["acceptEdits", "bypassPermissions"]: set to "implementing"
|
|
53
|
+
"""
|
|
54
|
+
context = get_context(context_id, project_root)
|
|
55
|
+
if not context or not context.in_flight:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
current_mode = context.in_flight.mode
|
|
59
|
+
permission_mode = hook_input.get("permission_mode", "default")
|
|
60
|
+
eprint(f"[user_prompt_submit] Current mode: {current_mode}, permission_mode: {permission_mode}")
|
|
61
|
+
|
|
62
|
+
# Clear handoff_pending if set (session resumption clears the handoff)
|
|
63
|
+
if current_mode == "handoff_pending":
|
|
64
|
+
clear_handoff_status(context_id, project_root)
|
|
65
|
+
eprint(f"[user_prompt_submit] Cleared handoff_pending status")
|
|
66
|
+
# Refresh context after clearing
|
|
67
|
+
context = get_context(context_id, project_root)
|
|
68
|
+
current_mode = context.in_flight.mode if context and context.in_flight else "none"
|
|
69
|
+
|
|
70
|
+
# Set status based on permission mode
|
|
71
|
+
if permission_mode == "plan":
|
|
72
|
+
if current_mode != "planning":
|
|
73
|
+
update_plan_status(context_id, "planning", project_root=project_root)
|
|
74
|
+
eprint(f"[user_prompt_submit] Set status to 'planning'")
|
|
75
|
+
elif permission_mode in ["acceptEdits", "bypassPermissions"]:
|
|
76
|
+
# Only transition to implementing if we have pending work
|
|
77
|
+
if current_mode in ["pending_implementation", "planning"]:
|
|
78
|
+
update_plan_status(context_id, "implementing", project_root=project_root)
|
|
79
|
+
eprint(f"[user_prompt_submit] Set status to 'implementing'")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main():
|
|
83
|
+
"""
|
|
84
|
+
Main entry point for UserPromptSubmit hook.
|
|
85
|
+
|
|
86
|
+
Handles context enforcement for all user prompts.
|
|
87
|
+
Uses session_id to detect first prompt vs subsequent prompts.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
# Read hook input from stdin
|
|
91
|
+
input_data = sys.stdin.read().strip()
|
|
92
|
+
|
|
93
|
+
if not input_data:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
hook_input = json.loads(input_data)
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Get user prompt and project root
|
|
102
|
+
user_prompt = hook_input.get("prompt", "")
|
|
103
|
+
project_root = project_dir(hook_input)
|
|
104
|
+
session_id = hook_input.get("session_id", "unknown")
|
|
105
|
+
|
|
106
|
+
outputs: List[str] = []
|
|
107
|
+
|
|
108
|
+
# First-prompt detection: check if session_id is already bound to a context
|
|
109
|
+
existing_context = get_context_by_session_id(session_id, project_root)
|
|
110
|
+
|
|
111
|
+
if existing_context:
|
|
112
|
+
# NOT first prompt - session already bound to context
|
|
113
|
+
# Skip expensive context detection and task hydration
|
|
114
|
+
eprint(f"[user_prompt_submit] Session {session_id[:8]}... already bound to {existing_context.id}")
|
|
115
|
+
# Still update in-flight status based on permission mode
|
|
116
|
+
_update_in_flight_status(existing_context.id, hook_input, project_root)
|
|
117
|
+
elif user_prompt:
|
|
118
|
+
# FIRST prompt - need context detection and potentially task hydration
|
|
119
|
+
try:
|
|
120
|
+
context_id, method, context_output = determine_context(user_prompt, project_root, session_id)
|
|
121
|
+
eprint(f"[user_prompt_submit] Context: {method} -> {context_id}")
|
|
122
|
+
|
|
123
|
+
if context_id:
|
|
124
|
+
# Bind session to context
|
|
125
|
+
update_context_session_id(context_id, session_id, project_root)
|
|
126
|
+
eprint(f"[user_prompt_submit] Bound session {session_id[:8]}... to context '{context_id}'")
|
|
127
|
+
|
|
128
|
+
# Update in-flight status based on permission mode
|
|
129
|
+
_update_in_flight_status(context_id, hook_input, project_root)
|
|
130
|
+
|
|
131
|
+
# Task hydration - restore pending tasks from events.jsonl
|
|
132
|
+
hydration_instructions = generate_hydration_instructions(context_id, project_root)
|
|
133
|
+
if hydration_instructions and "No pending tasks" not in hydration_instructions:
|
|
134
|
+
outputs.append(hydration_instructions)
|
|
135
|
+
eprint(f"[user_prompt_submit] Generated task hydration instructions")
|
|
136
|
+
|
|
137
|
+
if context_output:
|
|
138
|
+
outputs.append(context_output)
|
|
139
|
+
|
|
140
|
+
except BlockRequest as e:
|
|
141
|
+
# Block the request - print to stderr and exit with code 2
|
|
142
|
+
# This shows the context picker to the user
|
|
143
|
+
print(e.message, file=sys.stderr)
|
|
144
|
+
sys.exit(2)
|
|
145
|
+
|
|
146
|
+
# Print output
|
|
147
|
+
if outputs:
|
|
148
|
+
print("\n\n".join(outputs))
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
eprint(f"[user_prompt_submit] ERROR: {e}")
|
|
152
|
+
import traceback
|
|
153
|
+
eprint(traceback.format_exc())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared library for AIW CLI templates."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Base utilities for shared context management."""
|
|
2
|
+
from .atomic_write import atomic_write, atomic_append
|
|
3
|
+
from .constants import (
|
|
4
|
+
OUTPUT_DIR,
|
|
5
|
+
CONTEXTS_DIR,
|
|
6
|
+
INDEX_FILENAME,
|
|
7
|
+
validate_context_id,
|
|
8
|
+
get_output_dir,
|
|
9
|
+
get_contexts_dir,
|
|
10
|
+
get_context_dir,
|
|
11
|
+
get_context_plans_dir,
|
|
12
|
+
get_context_handoffs_dir,
|
|
13
|
+
get_index_path,
|
|
14
|
+
get_context_file_path,
|
|
15
|
+
get_events_file_path,
|
|
16
|
+
)
|
|
17
|
+
from .utils import (
|
|
18
|
+
eprint,
|
|
19
|
+
now_local,
|
|
20
|
+
now_iso,
|
|
21
|
+
project_dir,
|
|
22
|
+
sanitize_filename,
|
|
23
|
+
sanitize_title,
|
|
24
|
+
generate_context_id,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"atomic_write",
|
|
29
|
+
"atomic_append",
|
|
30
|
+
"OUTPUT_DIR",
|
|
31
|
+
"CONTEXTS_DIR",
|
|
32
|
+
"INDEX_FILENAME",
|
|
33
|
+
"validate_context_id",
|
|
34
|
+
"get_output_dir",
|
|
35
|
+
"get_contexts_dir",
|
|
36
|
+
"get_context_dir",
|
|
37
|
+
"get_context_plans_dir",
|
|
38
|
+
"get_context_handoffs_dir",
|
|
39
|
+
"get_index_path",
|
|
40
|
+
"get_context_file_path",
|
|
41
|
+
"get_events_file_path",
|
|
42
|
+
"eprint",
|
|
43
|
+
"now_local",
|
|
44
|
+
"now_iso",
|
|
45
|
+
"project_dir",
|
|
46
|
+
"sanitize_filename",
|
|
47
|
+
"sanitize_title",
|
|
48
|
+
"generate_context_id",
|
|
49
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Cross-platform atomic file writes with security.
|
|
2
|
+
|
|
3
|
+
Provides crash-safe file writes by writing to a temp file first,
|
|
4
|
+
then atomically replacing the target. This prevents corrupted files
|
|
5
|
+
if the process crashes mid-write.
|
|
6
|
+
|
|
7
|
+
Note: This is for crash-safety, NOT for concurrent access.
|
|
8
|
+
The shared context system assumes single-session-per-context.
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
if sys.platform == 'win32':
|
|
18
|
+
import ctypes
|
|
19
|
+
from ctypes import wintypes
|
|
20
|
+
|
|
21
|
+
# Windows MoveFileEx flags
|
|
22
|
+
MOVEFILE_REPLACE_EXISTING = 0x1
|
|
23
|
+
MOVEFILE_WRITE_THROUGH = 0x8
|
|
24
|
+
|
|
25
|
+
def _atomic_replace_windows(src: Path, dst: Path) -> None:
|
|
26
|
+
"""Atomic file replacement on Windows using MoveFileEx."""
|
|
27
|
+
kernel32 = ctypes.windll.kernel32
|
|
28
|
+
|
|
29
|
+
# Set proper function prototypes for 64-bit safety
|
|
30
|
+
kernel32.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
|
|
31
|
+
kernel32.MoveFileExW.restype = wintypes.BOOL
|
|
32
|
+
|
|
33
|
+
result = kernel32.MoveFileExW(
|
|
34
|
+
str(src),
|
|
35
|
+
str(dst),
|
|
36
|
+
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
|
|
37
|
+
)
|
|
38
|
+
if not result:
|
|
39
|
+
error_code = kernel32.GetLastError()
|
|
40
|
+
raise ctypes.WinError(error_code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def atomic_write(
|
|
44
|
+
path: Path,
|
|
45
|
+
content: str,
|
|
46
|
+
max_attempts: int = 2,
|
|
47
|
+
backoff_ms: Optional[list] = None
|
|
48
|
+
) -> Tuple[bool, Optional[str]]:
|
|
49
|
+
"""
|
|
50
|
+
Write file atomically with retry logic.
|
|
51
|
+
|
|
52
|
+
Creates a temp file in the same directory, writes content,
|
|
53
|
+
then atomically replaces the target file. This ensures the
|
|
54
|
+
file is never left in a corrupted state.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: Target file path
|
|
58
|
+
content: Content to write
|
|
59
|
+
max_attempts: Maximum retry attempts (default: 2)
|
|
60
|
+
backoff_ms: Retry backoff in milliseconds (default: [500, 1000])
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
64
|
+
"""
|
|
65
|
+
if backoff_ms is None:
|
|
66
|
+
backoff_ms = [500, 1000]
|
|
67
|
+
|
|
68
|
+
# Ensure parent directory exists
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
for attempt in range(max_attempts):
|
|
72
|
+
try:
|
|
73
|
+
# Create temp file in same directory for atomic rename
|
|
74
|
+
temp_fd, temp_path_str = tempfile.mkstemp(
|
|
75
|
+
dir=path.parent,
|
|
76
|
+
prefix=f".{path.stem}_",
|
|
77
|
+
suffix=".tmp"
|
|
78
|
+
)
|
|
79
|
+
temp_path = Path(temp_path_str)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# Write content to temp file
|
|
83
|
+
with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
|
|
84
|
+
f.write(content)
|
|
85
|
+
f.flush()
|
|
86
|
+
os.fsync(f.fileno()) # Force write to disk
|
|
87
|
+
|
|
88
|
+
# Set restrictive permissions before rename (chmod 600)
|
|
89
|
+
try:
|
|
90
|
+
os.chmod(temp_path, 0o600)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass # chmod may fail on some filesystems
|
|
93
|
+
|
|
94
|
+
# Platform-specific atomic rename
|
|
95
|
+
if sys.platform == 'win32':
|
|
96
|
+
_atomic_replace_windows(temp_path, path)
|
|
97
|
+
else:
|
|
98
|
+
temp_path.replace(path) # POSIX atomic
|
|
99
|
+
|
|
100
|
+
return (True, None)
|
|
101
|
+
|
|
102
|
+
except Exception:
|
|
103
|
+
# Clean up temp file on failure
|
|
104
|
+
try:
|
|
105
|
+
temp_path.unlink()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # Cleanup is best-effort
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
if attempt < max_attempts - 1:
|
|
112
|
+
# Bounds-safe backoff indexing
|
|
113
|
+
wait_ms = backoff_ms[min(attempt, len(backoff_ms) - 1)]
|
|
114
|
+
time.sleep(wait_ms / 1000.0)
|
|
115
|
+
else:
|
|
116
|
+
# Sanitize error message (no paths, no stack trace)
|
|
117
|
+
error_type = type(e).__name__
|
|
118
|
+
error_msg = str(e).split('\n')[0][:200] # First line only, max 200 chars
|
|
119
|
+
return (False, f"{error_type}: {error_msg}")
|
|
120
|
+
|
|
121
|
+
return (False, "Max retry attempts exceeded")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def atomic_append(
|
|
125
|
+
path: Path,
|
|
126
|
+
content: str,
|
|
127
|
+
max_attempts: int = 2,
|
|
128
|
+
backoff_ms: Optional[list] = None
|
|
129
|
+
) -> Tuple[bool, Optional[str]]:
|
|
130
|
+
"""
|
|
131
|
+
Append to file atomically with retry logic.
|
|
132
|
+
|
|
133
|
+
For JSONL files, this is safe because each line is independent.
|
|
134
|
+
If process crashes mid-append, only the last partial line is lost,
|
|
135
|
+
which read_events() handles gracefully.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
path: Target file path
|
|
139
|
+
content: Content to append (should include newline if needed)
|
|
140
|
+
max_attempts: Maximum retry attempts (default: 2)
|
|
141
|
+
backoff_ms: Retry backoff in milliseconds (default: [500, 1000])
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
145
|
+
"""
|
|
146
|
+
if backoff_ms is None:
|
|
147
|
+
backoff_ms = [500, 1000]
|
|
148
|
+
|
|
149
|
+
# Ensure parent directory exists
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
# Check if file is being created (for permission setting)
|
|
153
|
+
is_new_file = not path.exists()
|
|
154
|
+
|
|
155
|
+
for attempt in range(max_attempts):
|
|
156
|
+
try:
|
|
157
|
+
with open(path, 'a', encoding='utf-8') as f:
|
|
158
|
+
f.write(content)
|
|
159
|
+
f.flush()
|
|
160
|
+
os.fsync(f.fileno()) # Force write to disk
|
|
161
|
+
|
|
162
|
+
# Set restrictive permissions on newly created files (chmod 600)
|
|
163
|
+
if is_new_file:
|
|
164
|
+
try:
|
|
165
|
+
os.chmod(path, 0o600)
|
|
166
|
+
except OSError:
|
|
167
|
+
pass # chmod may fail on some filesystems
|
|
168
|
+
|
|
169
|
+
return (True, None)
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
if attempt < max_attempts - 1:
|
|
173
|
+
wait_ms = backoff_ms[min(attempt, len(backoff_ms) - 1)]
|
|
174
|
+
time.sleep(wait_ms / 1000.0)
|
|
175
|
+
else:
|
|
176
|
+
error_type = type(e).__name__
|
|
177
|
+
error_msg = str(e).split('\n')[0][:200]
|
|
178
|
+
return (False, f"{error_type}: {error_msg}")
|
|
179
|
+
|
|
180
|
+
return (False, "Max retry attempts exceeded")
|