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,299 @@
|
|
|
1
|
+
"""Constants and path utilities for shared context management.
|
|
2
|
+
|
|
3
|
+
Data hierarchy:
|
|
4
|
+
events.jsonl (source of truth)
|
|
5
|
+
→ context.json (L1 cache)
|
|
6
|
+
→ index.json (L2 cache)
|
|
7
|
+
|
|
8
|
+
All data written to _output/contexts/ (method-agnostic).
|
|
9
|
+
No method subfolders - method is just metadata on the context.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Directory names (relative to project root)
|
|
16
|
+
OUTPUT_DIR = "_output"
|
|
17
|
+
CONTEXTS_DIR = "contexts"
|
|
18
|
+
ARCHIVE_DIR = "archive"
|
|
19
|
+
INDEX_FILENAME = "index.json"
|
|
20
|
+
|
|
21
|
+
# Context ID validation
|
|
22
|
+
MAX_CONTEXT_ID_LENGTH = 64
|
|
23
|
+
VALID_CONTEXT_ID_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$')
|
|
24
|
+
|
|
25
|
+
# File size limits
|
|
26
|
+
MAX_EVENT_SIZE = 64 * 1024 # 64KB per event (reasonable limit)
|
|
27
|
+
MAX_INDEX_SIZE = 1024 * 1024 # 1MB for index.json
|
|
28
|
+
|
|
29
|
+
# Performance constants
|
|
30
|
+
MAX_RETRY_ATTEMPTS = 2
|
|
31
|
+
RETRY_BACKOFF_MS = [500, 1000]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sanitize_context_id(context_id: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Sanitize a string into a valid context ID.
|
|
37
|
+
|
|
38
|
+
Performs these transformations:
|
|
39
|
+
- Convert to lowercase
|
|
40
|
+
- Replace invalid characters with hyphens
|
|
41
|
+
- Collapse consecutive hyphens/underscores
|
|
42
|
+
- Strip leading/trailing non-alphanumeric
|
|
43
|
+
- Truncate to MAX_CONTEXT_ID_LENGTH
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
context_id: Raw input string
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Valid context ID string, or "context" if input is empty/invalid
|
|
50
|
+
"""
|
|
51
|
+
if not context_id:
|
|
52
|
+
return "context"
|
|
53
|
+
|
|
54
|
+
# Normalize to lowercase
|
|
55
|
+
result = context_id.lower()
|
|
56
|
+
|
|
57
|
+
# Replace any character that's not alphanumeric, hyphen, or underscore
|
|
58
|
+
result = re.sub(r'[^a-z0-9_-]', '-', result)
|
|
59
|
+
|
|
60
|
+
# Collapse consecutive hyphens/underscores into single hyphen
|
|
61
|
+
result = re.sub(r'[-_]+', '-', result)
|
|
62
|
+
|
|
63
|
+
# Strip leading/trailing non-alphanumeric
|
|
64
|
+
result = result.strip('-_')
|
|
65
|
+
|
|
66
|
+
# Truncate to max length
|
|
67
|
+
if len(result) > MAX_CONTEXT_ID_LENGTH:
|
|
68
|
+
result = result[:MAX_CONTEXT_ID_LENGTH].rstrip('-_')
|
|
69
|
+
|
|
70
|
+
# If nothing left, return default
|
|
71
|
+
return result if result else "context"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_context_id(context_id: str) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Validate and normalize context ID.
|
|
77
|
+
|
|
78
|
+
Auto-sanitizes invalid input instead of throwing errors for format issues.
|
|
79
|
+
Only throws for security violations (path traversal).
|
|
80
|
+
|
|
81
|
+
Valid context IDs:
|
|
82
|
+
- 1-64 characters
|
|
83
|
+
- lowercase alphanumeric, hyphens, underscores
|
|
84
|
+
- must start and end with alphanumeric
|
|
85
|
+
- no consecutive hyphens/underscores
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: Only for path traversal attempts
|
|
89
|
+
"""
|
|
90
|
+
if not context_id:
|
|
91
|
+
return "context"
|
|
92
|
+
|
|
93
|
+
# SECURITY: Check for path traversal BEFORE any normalization
|
|
94
|
+
# This prevents encoded or case-variant attacks
|
|
95
|
+
if '..' in context_id or '/' in context_id or '\\' in context_id:
|
|
96
|
+
raise ValueError(f"Invalid context ID '{context_id}': path traversal not allowed")
|
|
97
|
+
|
|
98
|
+
# Also check for URL-encoded variants
|
|
99
|
+
if '%2e' in context_id.lower() or '%2f' in context_id.lower() or '%5c' in context_id.lower():
|
|
100
|
+
raise ValueError(f"Invalid context ID '{context_id}': encoded path traversal not allowed")
|
|
101
|
+
|
|
102
|
+
# Sanitize instead of throwing for format issues
|
|
103
|
+
sanitized = sanitize_context_id(context_id)
|
|
104
|
+
|
|
105
|
+
return sanitized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_output_dir(project_root: Path = None) -> Path:
|
|
109
|
+
"""
|
|
110
|
+
Get the output directory path.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
project_root: Project root directory (default: cwd)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Path to _output/
|
|
117
|
+
"""
|
|
118
|
+
if project_root is None:
|
|
119
|
+
project_root = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()))
|
|
120
|
+
return Path(project_root) / OUTPUT_DIR
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_contexts_dir(project_root: Path = None) -> Path:
|
|
124
|
+
"""
|
|
125
|
+
Get the contexts directory path.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
project_root: Project root directory (default: cwd)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Path to _output/contexts/
|
|
132
|
+
"""
|
|
133
|
+
return get_output_dir(project_root) / CONTEXTS_DIR
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_context_dir(context_id: str, project_root: Path = None) -> Path:
|
|
137
|
+
"""
|
|
138
|
+
Get the directory path for a specific context.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
context_id: Context identifier
|
|
142
|
+
project_root: Project root directory (default: cwd)
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Path to _output/contexts/{context_id}/
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: If context_id is invalid or path escapes expected directory
|
|
149
|
+
"""
|
|
150
|
+
validated_id = validate_context_id(context_id)
|
|
151
|
+
contexts_dir = get_contexts_dir(project_root)
|
|
152
|
+
result_path = contexts_dir / validated_id
|
|
153
|
+
|
|
154
|
+
# SECURITY: Verify resolved path stays within contexts directory
|
|
155
|
+
# This prevents symlink attacks and any path manipulation we might have missed
|
|
156
|
+
try:
|
|
157
|
+
resolved = result_path.resolve()
|
|
158
|
+
contexts_resolved = contexts_dir.resolve()
|
|
159
|
+
# Check that resolved path starts with the contexts directory
|
|
160
|
+
# Use os.path for cross-platform compatibility
|
|
161
|
+
import os
|
|
162
|
+
resolved_str = os.path.normcase(str(resolved))
|
|
163
|
+
contexts_str = os.path.normcase(str(contexts_resolved))
|
|
164
|
+
if not resolved_str.startswith(contexts_str):
|
|
165
|
+
raise ValueError(f"Invalid context ID '{context_id}': path escapes contexts directory")
|
|
166
|
+
except (OSError, ValueError) as e:
|
|
167
|
+
if isinstance(e, ValueError):
|
|
168
|
+
raise
|
|
169
|
+
# OSError can occur if path doesn't exist yet, which is fine for creation
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
return result_path
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_context_plans_dir(context_id: str, project_root: Path = None) -> Path:
|
|
176
|
+
"""
|
|
177
|
+
Get the plans directory for a specific context.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
context_id: Context identifier
|
|
181
|
+
project_root: Project root directory (default: cwd)
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Path to _output/contexts/{context_id}/plans/
|
|
185
|
+
"""
|
|
186
|
+
return get_context_dir(context_id, project_root) / "plans"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_context_handoffs_dir(context_id: str, project_root: Path = None) -> Path:
|
|
190
|
+
"""
|
|
191
|
+
Get the handoffs directory for a specific context.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
context_id: Context identifier
|
|
195
|
+
project_root: Project root directory (default: cwd)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Path to _output/contexts/{context_id}/handoffs/
|
|
199
|
+
"""
|
|
200
|
+
return get_context_dir(context_id, project_root) / "handoffs"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_context_reviews_dir(context_id: str, project_root: Path = None) -> Path:
|
|
204
|
+
"""
|
|
205
|
+
Get the reviews directory for a specific context.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
context_id: Context identifier
|
|
209
|
+
project_root: Project root directory (default: cwd)
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Path to _output/contexts/{context_id}/reviews/
|
|
213
|
+
"""
|
|
214
|
+
return get_context_dir(context_id, project_root) / "reviews"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_index_path(project_root: Path = None) -> Path:
|
|
218
|
+
"""
|
|
219
|
+
Get the global index file path.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
project_root: Project root directory (default: cwd)
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Path to _output/index.json
|
|
226
|
+
"""
|
|
227
|
+
return get_output_dir(project_root) / INDEX_FILENAME
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_context_file_path(context_id: str, project_root: Path = None) -> Path:
|
|
231
|
+
"""
|
|
232
|
+
Get the context.json file path for a specific context.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
context_id: Context identifier
|
|
236
|
+
project_root: Project root directory (default: cwd)
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Path to _output/contexts/{context_id}/context.json
|
|
240
|
+
"""
|
|
241
|
+
return get_context_dir(context_id, project_root) / "context.json"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_events_file_path(context_id: str, project_root: Path = None) -> Path:
|
|
245
|
+
"""
|
|
246
|
+
Get the events.jsonl file path for a specific context.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
context_id: Context identifier
|
|
250
|
+
project_root: Project root directory (default: cwd)
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Path to _output/contexts/{context_id}/events.jsonl
|
|
254
|
+
"""
|
|
255
|
+
return get_context_dir(context_id, project_root) / "events.jsonl"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_archive_dir(project_root: Path = None) -> Path:
|
|
259
|
+
"""
|
|
260
|
+
Get the archive directory path.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
project_root: Project root directory (default: cwd)
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Path to _output/contexts/archive/
|
|
267
|
+
"""
|
|
268
|
+
return get_contexts_dir(project_root) / ARCHIVE_DIR
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_archive_context_dir(context_id: str, project_root: Path = None) -> Path:
|
|
272
|
+
"""
|
|
273
|
+
Get the archive directory for a specific context.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
context_id: Context identifier
|
|
277
|
+
project_root: Project root directory (default: cwd)
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Path to _output/contexts/archive/{context_id}/
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ValueError: If context_id is invalid
|
|
284
|
+
"""
|
|
285
|
+
validated_id = validate_context_id(context_id)
|
|
286
|
+
return get_archive_dir(project_root) / validated_id
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_archive_index_path(project_root: Path = None) -> Path:
|
|
290
|
+
"""
|
|
291
|
+
Get the archive index file path.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
project_root: Project root directory (default: cwd)
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Path to _output/contexts/archive/index.json
|
|
298
|
+
"""
|
|
299
|
+
return get_archive_dir(project_root) / INDEX_FILENAME
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Inference utility for AI-powered text processing.
|
|
2
|
+
|
|
3
|
+
Provides a unified interface for Claude API calls using the claude CLI.
|
|
4
|
+
Supports multiple model tiers: fast (Haiku), standard (Sonnet), smart (Opus).
|
|
5
|
+
"""
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class InferenceResult:
|
|
15
|
+
"""Result from an inference call."""
|
|
16
|
+
success: bool
|
|
17
|
+
output: str
|
|
18
|
+
error: Optional[str] = None
|
|
19
|
+
latency_ms: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Model configurations
|
|
23
|
+
MODELS = {
|
|
24
|
+
"fast": "claude-3-haiku-20240307",
|
|
25
|
+
"standard": "claude-sonnet-4-20250514",
|
|
26
|
+
"smart": "claude-opus-4-20250514",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
TIMEOUTS = {
|
|
30
|
+
"fast": 15, # 15 seconds
|
|
31
|
+
"standard": 30, # 30 seconds
|
|
32
|
+
"smart": 90, # 90 seconds
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inference(
|
|
37
|
+
system_prompt: str,
|
|
38
|
+
user_prompt: str,
|
|
39
|
+
level: str = "fast",
|
|
40
|
+
timeout: Optional[int] = None,
|
|
41
|
+
) -> InferenceResult:
|
|
42
|
+
"""
|
|
43
|
+
Run inference using the claude CLI.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
system_prompt: System instructions for the model
|
|
47
|
+
user_prompt: User message to process
|
|
48
|
+
level: Model level - "fast" (Haiku), "standard" (Sonnet), "smart" (Opus)
|
|
49
|
+
timeout: Custom timeout in seconds (uses level default if not specified)
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
InferenceResult with success status, output, and any error
|
|
53
|
+
"""
|
|
54
|
+
import time
|
|
55
|
+
start_time = time.time()
|
|
56
|
+
|
|
57
|
+
model = MODELS.get(level, MODELS["fast"])
|
|
58
|
+
timeout_sec = timeout or TIMEOUTS.get(level, TIMEOUTS["fast"])
|
|
59
|
+
|
|
60
|
+
# Combine prompts
|
|
61
|
+
full_prompt = f"{system_prompt}\n\n{user_prompt}"
|
|
62
|
+
|
|
63
|
+
# Build command
|
|
64
|
+
cmd = [
|
|
65
|
+
"claude",
|
|
66
|
+
"--model", model,
|
|
67
|
+
"--print",
|
|
68
|
+
"--no-hooks",
|
|
69
|
+
"-p", full_prompt,
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Remove ANTHROPIC_API_KEY to force subscription auth
|
|
73
|
+
env = os.environ.copy()
|
|
74
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
cmd,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=timeout_sec,
|
|
82
|
+
env=env,
|
|
83
|
+
# Windows needs shell=True for command resolution
|
|
84
|
+
shell=(sys.platform == "win32"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
88
|
+
|
|
89
|
+
if result.returncode != 0:
|
|
90
|
+
return InferenceResult(
|
|
91
|
+
success=False,
|
|
92
|
+
output=result.stdout.strip() if result.stdout else "",
|
|
93
|
+
error=result.stderr.strip() if result.stderr else f"Exit code: {result.returncode}",
|
|
94
|
+
latency_ms=latency_ms,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return InferenceResult(
|
|
98
|
+
success=True,
|
|
99
|
+
output=result.stdout.strip(),
|
|
100
|
+
latency_ms=latency_ms,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
105
|
+
return InferenceResult(
|
|
106
|
+
success=False,
|
|
107
|
+
output="",
|
|
108
|
+
error=f"Timeout after {timeout_sec}s",
|
|
109
|
+
latency_ms=latency_ms,
|
|
110
|
+
)
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
113
|
+
return InferenceResult(
|
|
114
|
+
success=False,
|
|
115
|
+
output="",
|
|
116
|
+
error="claude CLI not found",
|
|
117
|
+
latency_ms=latency_ms,
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
121
|
+
return InferenceResult(
|
|
122
|
+
success=False,
|
|
123
|
+
output="",
|
|
124
|
+
error=str(e),
|
|
125
|
+
latency_ms=latency_ms,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# System prompt for generating context ID summaries
|
|
130
|
+
CONTEXT_ID_SYSTEM_PROMPT = """Generate a 10-word summary of what the user wants to do. Start with a gerund (verb ending in -ing).
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
- Exactly 10 words
|
|
134
|
+
- Start with gerund: Creating, Fixing, Adding, Updating, Implementing, etc.
|
|
135
|
+
- Be specific about the task
|
|
136
|
+
- No punctuation
|
|
137
|
+
- No quotes
|
|
138
|
+
|
|
139
|
+
Examples:
|
|
140
|
+
- "I want to add user authentication" -> "Adding user authentication with JWT tokens to the web app"
|
|
141
|
+
- "Fix the bug in the login flow" -> "Fixing critical bug in user login flow validation logic"
|
|
142
|
+
- "Can you help me refactor this code" -> "Refactoring legacy code for better maintainability and cleaner architecture"
|
|
143
|
+
- "Update the README with new instructions" -> "Updating README with new setup instructions and configuration examples"
|
|
144
|
+
|
|
145
|
+
Output ONLY the 10-word summary, nothing else."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def generate_semantic_summary(prompt: str, timeout: int = 15) -> Optional[str]:
|
|
149
|
+
"""
|
|
150
|
+
Generate a semantic 10-word summary of a user prompt.
|
|
151
|
+
|
|
152
|
+
Uses Sonnet for quality inference. Returns None if inference fails.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
prompt: User prompt to summarize
|
|
156
|
+
timeout: Timeout in seconds (default 15)
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
10-word summary string or None if failed
|
|
160
|
+
"""
|
|
161
|
+
# Pass full prompt - AI can summarize any length into 10 words
|
|
162
|
+
result = inference(
|
|
163
|
+
system_prompt=CONTEXT_ID_SYSTEM_PROMPT,
|
|
164
|
+
user_prompt=prompt,
|
|
165
|
+
level="standard",
|
|
166
|
+
timeout=timeout,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not result.success or not result.output:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Clean up the output
|
|
173
|
+
summary = result.output.strip()
|
|
174
|
+
# Remove any quotes
|
|
175
|
+
summary = summary.strip('"\'')
|
|
176
|
+
# Remove trailing punctuation
|
|
177
|
+
summary = summary.rstrip('.!?')
|
|
178
|
+
|
|
179
|
+
# Validate it starts with a gerund (capital letter + letters + "ing")
|
|
180
|
+
import re
|
|
181
|
+
if not re.match(r'^[A-Z][a-z]*ing\b', summary):
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Validate roughly 10 words (allow 8-12 for flexibility)
|
|
185
|
+
words = summary.split()
|
|
186
|
+
if len(words) < 8 or len(words) > 12:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
return summary
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Core utilities for shared context management.
|
|
2
|
+
|
|
3
|
+
Provides common functions used across all shared modules:
|
|
4
|
+
- eprint: Print to stderr
|
|
5
|
+
- now_local: Get current local datetime
|
|
6
|
+
- project_dir: Get project directory from environment
|
|
7
|
+
- sanitize_filename: Sanitize string for use in filenames
|
|
8
|
+
- generate_context_id: Generate a slug from summary text
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def eprint(*args: Any) -> None:
|
|
19
|
+
"""Print to stderr."""
|
|
20
|
+
print(*args, file=sys.stderr)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def now_local() -> datetime:
|
|
24
|
+
"""Get current local datetime."""
|
|
25
|
+
return datetime.now()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def now_iso() -> str:
|
|
29
|
+
"""Get current time as ISO 8601 string."""
|
|
30
|
+
return datetime.now().isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def project_dir(payload: Optional[Dict[str, Any]] = None) -> Path:
|
|
34
|
+
"""
|
|
35
|
+
Get project directory from payload or environment.
|
|
36
|
+
|
|
37
|
+
Priority:
|
|
38
|
+
1. CLAUDE_PROJECT_DIR environment variable
|
|
39
|
+
2. 'cwd' from payload (if provided)
|
|
40
|
+
3. Current working directory
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
payload: Optional hook payload with 'cwd' field
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Path to project directory
|
|
47
|
+
|
|
48
|
+
Note:
|
|
49
|
+
CLAUDE_PROJECT_DIR is validated to be an absolute path when provided.
|
|
50
|
+
"""
|
|
51
|
+
p = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
52
|
+
if p:
|
|
53
|
+
# Validate that CLAUDE_PROJECT_DIR is an absolute path
|
|
54
|
+
path = Path(p)
|
|
55
|
+
if not path.is_absolute():
|
|
56
|
+
eprint(f"[utils] WARNING: CLAUDE_PROJECT_DIR is not absolute, using cwd instead")
|
|
57
|
+
p = None
|
|
58
|
+
else:
|
|
59
|
+
# Check for suspicious patterns
|
|
60
|
+
if '..' in str(path):
|
|
61
|
+
eprint(f"[utils] WARNING: CLAUDE_PROJECT_DIR contains '..' pattern, using cwd instead")
|
|
62
|
+
p = None
|
|
63
|
+
|
|
64
|
+
if not p and payload:
|
|
65
|
+
p = payload.get("cwd")
|
|
66
|
+
if not p:
|
|
67
|
+
p = os.getcwd()
|
|
68
|
+
return Path(p)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Windows reserved filenames that should be blocked
|
|
72
|
+
_WINDOWS_RESERVED = frozenset([
|
|
73
|
+
'CON', 'PRN', 'AUX', 'NUL',
|
|
74
|
+
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
75
|
+
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sanitize_filename(s: str, max_len: int = 32, allow_leading_dot: bool = False) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Sanitize string for use in filename.
|
|
82
|
+
|
|
83
|
+
Replaces non-alphanumeric characters (except ._-) with underscores,
|
|
84
|
+
strips leading/trailing special characters, and truncates.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
s: Input string
|
|
88
|
+
max_len: Maximum length (default: 32)
|
|
89
|
+
allow_leading_dot: Whether to allow leading dots (default: False for security)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Sanitized filename-safe string
|
|
93
|
+
"""
|
|
94
|
+
s = re.sub(r"[^A-Za-z0-9._-]+", "_", s)
|
|
95
|
+
result = s.strip("._-")[:max_len] or "unknown"
|
|
96
|
+
|
|
97
|
+
# Remove leading dots unless explicitly allowed (prevents hidden files)
|
|
98
|
+
if not allow_leading_dot:
|
|
99
|
+
result = result.lstrip('.')
|
|
100
|
+
|
|
101
|
+
# Check for Windows reserved names
|
|
102
|
+
base_name = result.split('.')[0].upper()
|
|
103
|
+
if base_name in _WINDOWS_RESERVED:
|
|
104
|
+
result = f"_{result}"
|
|
105
|
+
|
|
106
|
+
return result or "unknown"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sanitize_title(s: str, max_len: int = 50) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Sanitize title for use in context ID or filename.
|
|
112
|
+
|
|
113
|
+
Converts spaces to hyphens, replaces special characters,
|
|
114
|
+
and normalizes to lowercase.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
s: Input string
|
|
118
|
+
max_len: Maximum length (default: 50)
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Sanitized slug-like string
|
|
122
|
+
"""
|
|
123
|
+
s = s.lower().strip()
|
|
124
|
+
s = s.replace(' ', '-')
|
|
125
|
+
s = re.sub(r"[^a-z0-9._-]+", "_", s)
|
|
126
|
+
s = re.sub(r"[-_]+", "-", s)
|
|
127
|
+
result = s.strip("._-")[:max_len] or "unknown"
|
|
128
|
+
|
|
129
|
+
# Check for Windows reserved names
|
|
130
|
+
base_name = result.split('.')[0].upper()
|
|
131
|
+
if base_name in _WINDOWS_RESERVED:
|
|
132
|
+
result = f"_{result}"
|
|
133
|
+
|
|
134
|
+
return result or "unknown"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def generate_context_id(summary: str, existing_ids: Optional[set] = None) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Generate a context ID from a summary string.
|
|
140
|
+
|
|
141
|
+
Uses AI inference to create a semantic 10-word summary, then slugifies it.
|
|
142
|
+
Falls back to truncate-and-slugify if inference fails.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
summary: Context summary text
|
|
146
|
+
existing_ids: Optional set of existing context IDs to avoid
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Unique context ID string
|
|
150
|
+
"""
|
|
151
|
+
if not summary or not summary.strip():
|
|
152
|
+
base_id = "context"
|
|
153
|
+
else:
|
|
154
|
+
# Try AI-powered semantic summary first
|
|
155
|
+
base_id = None
|
|
156
|
+
try:
|
|
157
|
+
from .inference import generate_semantic_summary
|
|
158
|
+
semantic = generate_semantic_summary(summary)
|
|
159
|
+
if semantic:
|
|
160
|
+
# Slugify the semantic summary
|
|
161
|
+
base_id = sanitize_title(semantic, max_len=60)
|
|
162
|
+
eprint(f"[utils] Semantic context ID: {base_id}")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
eprint(f"[utils] Inference failed, using fallback: {e}")
|
|
165
|
+
|
|
166
|
+
# Fallback to old method if inference failed
|
|
167
|
+
if not base_id:
|
|
168
|
+
base_id = sanitize_title(summary[:50])
|
|
169
|
+
|
|
170
|
+
if not existing_ids:
|
|
171
|
+
return base_id
|
|
172
|
+
|
|
173
|
+
# Ensure uniqueness by appending counter if needed
|
|
174
|
+
if base_id not in existing_ids:
|
|
175
|
+
return base_id
|
|
176
|
+
|
|
177
|
+
counter = 2
|
|
178
|
+
while f"{base_id}-{counter}" in existing_ids:
|
|
179
|
+
counter += 1
|
|
180
|
+
|
|
181
|
+
return f"{base_id}-{counter}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def format_timestamp(dt: Optional[datetime] = None) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Format datetime for display.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
dt: Datetime to format (default: now)
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Formatted string like "2026-01-25 10:30:00"
|
|
193
|
+
"""
|
|
194
|
+
if dt is None:
|
|
195
|
+
dt = now_local()
|
|
196
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_iso_timestamp(iso_str: str) -> Optional[datetime]:
|
|
200
|
+
"""
|
|
201
|
+
Parse ISO 8601 timestamp string.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
iso_str: ISO format timestamp
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
datetime object or None if parsing fails
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
# Handle both with and without microseconds
|
|
211
|
+
if '.' in iso_str:
|
|
212
|
+
return datetime.fromisoformat(iso_str)
|
|
213
|
+
else:
|
|
214
|
+
return datetime.fromisoformat(iso_str.replace('Z', '+00:00'))
|
|
215
|
+
except (ValueError, TypeError):
|
|
216
|
+
return None
|