@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5
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/CHANGELOG.md +79 -95
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +126 -245
- package/aesop.config.example.json +34 -26
- package/bin/CLAUDE.md +67 -0
- package/bin/cli.js +593 -36
- package/daemons/CLAUDE.md +36 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +95 -12
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELEASING.md +159 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/archive/README.md +3 -0
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/archive/spikes/tiered-cognition/README.md +27 -0
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +61 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +75 -88
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +613 -61
- package/package.json +15 -3
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/skills/power/SKILL.md +161 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +72 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +317 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +138 -31
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/stall_check.py +172 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +513 -0
- package/tools/verify_submit_encoding.py +202 -0
- package/ui/CLAUDE.md +125 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +183 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +97 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +735 -0
- package/ui/config.py +123 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +144 -0
- package/ui/handler.py +676 -0
- package/ui/render.py +49 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +183 -0
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Automated silent-hang detection for agent transcripts.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
stall_check.py [--transcripts-root DIR] [--threshold-seconds SEC] [--json] [--exit-nonzero-on-stall]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--transcripts-root DIR Root directory to scan for agent-*.jsonl transcripts.
|
|
10
|
+
Defaults to AESOP_TRANSCRIPTS_ROOT env var, or ~/.claude/projects if unset.
|
|
11
|
+
--threshold-seconds SEC Max age in seconds for a "fresh" transcript (default: 600).
|
|
12
|
+
Transcripts older than this are flagged as stalled.
|
|
13
|
+
--json Output as JSON list of {agent_id, age_seconds, stalled, last_mtime}.
|
|
14
|
+
--exit-nonzero-on-stall Exit 1 if any agent is detected as stalled; default exit 0 always.
|
|
15
|
+
|
|
16
|
+
Behavior:
|
|
17
|
+
- Walks transcripts-root for files matching agent-*.jsonl.
|
|
18
|
+
- For each file, computes age = now - file mtime (seconds).
|
|
19
|
+
- Reports agents as stalled if age > threshold-seconds.
|
|
20
|
+
- Default output: human-readable table; add --json for structured output.
|
|
21
|
+
- Gracefully reports "no transcripts found" if root is missing or empty.
|
|
22
|
+
- Exit code: 0 always (unless --exit-nonzero-on-stall specified and stalls detected).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import json
|
|
29
|
+
import argparse
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_transcripts_root():
|
|
34
|
+
"""Resolve transcripts root from env var or default to ~/.claude/projects."""
|
|
35
|
+
if os.environ.get("AESOP_TRANSCRIPTS_ROOT"):
|
|
36
|
+
return Path(os.environ["AESOP_TRANSCRIPTS_ROOT"])
|
|
37
|
+
# Default to ~/.claude/projects
|
|
38
|
+
return Path.home() / ".claude" / "projects"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def scan_transcripts(transcripts_root, threshold_seconds):
|
|
42
|
+
"""Scan transcripts root for agent-*.jsonl files and compute staleness.
|
|
43
|
+
|
|
44
|
+
Returns: list of dicts {agent, transcript, mtime_age_s, verdict, suggested_action, last_mtime (ISO)}
|
|
45
|
+
"""
|
|
46
|
+
transcripts_root = Path(transcripts_root)
|
|
47
|
+
|
|
48
|
+
if not transcripts_root.exists():
|
|
49
|
+
return None # Signal: root missing
|
|
50
|
+
|
|
51
|
+
now = time.time()
|
|
52
|
+
results = []
|
|
53
|
+
|
|
54
|
+
# Thresholds for verdict classification
|
|
55
|
+
STALE_THRESHOLD = threshold_seconds
|
|
56
|
+
DEAD_THRESHOLD = threshold_seconds * 2 # Dead if 2x threshold
|
|
57
|
+
|
|
58
|
+
# Walk all subdirectories for agent-*.jsonl files
|
|
59
|
+
for jsonl_file in transcripts_root.rglob("agent-*.jsonl"):
|
|
60
|
+
if not jsonl_file.is_file():
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
mtime = jsonl_file.stat().st_mtime
|
|
64
|
+
age_seconds = int(now - mtime)
|
|
65
|
+
|
|
66
|
+
# Extract agent_id from filename (e.g., agent-abc123.jsonl -> abc123)
|
|
67
|
+
agent_id = jsonl_file.stem.replace("agent-", "")
|
|
68
|
+
|
|
69
|
+
# Determine verdict
|
|
70
|
+
if age_seconds <= STALE_THRESHOLD:
|
|
71
|
+
verdict = "ok"
|
|
72
|
+
suggested_action = None
|
|
73
|
+
elif age_seconds <= DEAD_THRESHOLD:
|
|
74
|
+
verdict = "stale"
|
|
75
|
+
suggested_action = "monitor for progress or investigate why transcript is stalled"
|
|
76
|
+
else:
|
|
77
|
+
verdict = "dead"
|
|
78
|
+
suggested_action = "investigate immediately; agent may be hung or crashed"
|
|
79
|
+
|
|
80
|
+
results.append({
|
|
81
|
+
"agent": agent_id,
|
|
82
|
+
"transcript": str(jsonl_file),
|
|
83
|
+
"mtime_age_s": age_seconds,
|
|
84
|
+
"verdict": verdict,
|
|
85
|
+
"suggested_action": suggested_action,
|
|
86
|
+
"last_mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mtime)),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return results
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_human_table(results):
|
|
93
|
+
"""Print results as a human-readable table."""
|
|
94
|
+
if not results:
|
|
95
|
+
print("no transcripts found")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Sort by mtime_age_s (oldest first)
|
|
99
|
+
sorted_results = sorted(results, key=lambda r: r["mtime_age_s"], reverse=True)
|
|
100
|
+
|
|
101
|
+
# Header
|
|
102
|
+
print(f"{'AGENT':<30} {'AGE (s)':<10} {'VERDICT':<10} {'ACTION':<40}")
|
|
103
|
+
print("-" * 90)
|
|
104
|
+
|
|
105
|
+
for entry in sorted_results:
|
|
106
|
+
action = entry["suggested_action"] or "—"
|
|
107
|
+
# Truncate action for display
|
|
108
|
+
action = action[:38] if len(action) > 38 else action
|
|
109
|
+
print(f"{entry['agent']:<30} {entry['mtime_age_s']:<10} {entry['verdict']:<10} {action:<40}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def print_json_output(results):
|
|
113
|
+
"""Print results as JSON."""
|
|
114
|
+
if results is None:
|
|
115
|
+
print(json.dumps([]))
|
|
116
|
+
else:
|
|
117
|
+
print(json.dumps(results, indent=2))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main():
|
|
121
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--transcripts-root",
|
|
124
|
+
default=None,
|
|
125
|
+
help="Root directory to scan for agent-*.jsonl files (default: AESOP_TRANSCRIPTS_ROOT or ~/.claude/projects)",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--threshold-seconds",
|
|
129
|
+
type=int,
|
|
130
|
+
default=600,
|
|
131
|
+
help="Max age in seconds for a 'fresh' transcript (default: 600)",
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--json",
|
|
135
|
+
action="store_true",
|
|
136
|
+
help="Output as JSON instead of human-readable table",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--exit-nonzero-on-stall",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="Exit 1 if any agent is stalled (default: always exit 0)",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
args = parser.parse_args()
|
|
145
|
+
|
|
146
|
+
# Resolve transcripts root
|
|
147
|
+
transcripts_root = args.transcripts_root if args.transcripts_root else get_transcripts_root()
|
|
148
|
+
|
|
149
|
+
# Scan transcripts
|
|
150
|
+
results = scan_transcripts(transcripts_root, args.threshold_seconds)
|
|
151
|
+
|
|
152
|
+
# Output
|
|
153
|
+
if args.json:
|
|
154
|
+
print_json_output(results)
|
|
155
|
+
else:
|
|
156
|
+
if results is None:
|
|
157
|
+
print("no transcripts found")
|
|
158
|
+
else:
|
|
159
|
+
print_human_table(results)
|
|
160
|
+
|
|
161
|
+
# Determine exit code
|
|
162
|
+
exit_code = 0
|
|
163
|
+
if args.exit_nonzero_on_stall and results:
|
|
164
|
+
has_stalled = any(r["verdict"] in ("stale", "dead") for r in results)
|
|
165
|
+
if has_stalled:
|
|
166
|
+
exit_code = 1
|
|
167
|
+
|
|
168
|
+
sys.exit(exit_code)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* svg_to_png.mjs — Rasterize an SVG file to PNG using @resvg/resvg-js.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: on systems without rsvg-convert, ImageMagick, or Inkscape,
|
|
6
|
+
* @resvg/resvg-js ships a prebuilt native binary via npm with zero extra system
|
|
7
|
+
* deps, making it the reliable local converter for cross-platform automation.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node svg_to_png.mjs <input.svg> <output.png> [width]
|
|
11
|
+
*
|
|
12
|
+
* If [width] is given, the SVG is rasterized at that pixel width (height keeps
|
|
13
|
+
* aspect ratio unless the SVG sets an explicit height). Requires
|
|
14
|
+
* @resvg/resvg-js to be installed (locally in the calling project, or
|
|
15
|
+
* globally) — install with: npm install @resvg/resvg-js
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
let Resvg;
|
|
20
|
+
try {
|
|
21
|
+
const resvgModule = await import("@resvg/resvg-js");
|
|
22
|
+
Resvg = resvgModule.Resvg;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error(
|
|
25
|
+
"Error: @resvg/resvg-js is not installed.\n" +
|
|
26
|
+
"Install it with: npm install @resvg/resvg-js\n" +
|
|
27
|
+
"Or globally with: npm install -g @resvg/resvg-js"
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [, , inputPath, outputPath, widthArg] = process.argv;
|
|
33
|
+
|
|
34
|
+
if (!inputPath || !outputPath) {
|
|
35
|
+
console.error("Usage: node svg_to_png.mjs <input.svg> <output.png> [width]");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const svg = readFileSync(inputPath, "utf8");
|
|
40
|
+
const opts = {};
|
|
41
|
+
if (widthArg) {
|
|
42
|
+
opts.fitTo = { mode: "width", value: parseInt(widthArg, 10) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resvg = new Resvg(svg, opts);
|
|
46
|
+
const pngData = resvg.render();
|
|
47
|
+
const buffer = pngData.asPng();
|
|
48
|
+
writeFileSync(outputPath, buffer);
|
|
49
|
+
|
|
50
|
+
console.log(`${outputPath}: ${pngData.width}x${pngData.height}`);
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Replay post-commit edits/writes from Claude Code transcripts to recover uncommitted work."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_edit(text, old_string, new_string, replace_all=False):
|
|
11
|
+
"""Apply a single edit to text. Returns updated text or None/undefined."""
|
|
12
|
+
if not isinstance(old_string, str) or not old_string or old_string not in text:
|
|
13
|
+
return None
|
|
14
|
+
if replace_all:
|
|
15
|
+
return text.split(old_string)[0] + new_string + text.split(old_string)[1]
|
|
16
|
+
idx = text.find(old_string)
|
|
17
|
+
return text[:idx] + new_string + text[idx + len(old_string) :]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def count_lines(text):
|
|
21
|
+
"""Count lines in text."""
|
|
22
|
+
return len(text.split("\n"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fmt_time(ts_ms):
|
|
26
|
+
"""Format timestamp in milliseconds to ISO format with Z suffix."""
|
|
27
|
+
if not ts_ms:
|
|
28
|
+
return "?"
|
|
29
|
+
return datetime.fromtimestamp(ts_ms / 1000).isoformat().replace("T", " ")[:19] + "Z"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def walk_jsonl(directory):
|
|
33
|
+
"""Recursively find all .jsonl files in a directory."""
|
|
34
|
+
result = []
|
|
35
|
+
for root, dirs, files in os.walk(directory):
|
|
36
|
+
for f in files:
|
|
37
|
+
if f.endswith(".jsonl"):
|
|
38
|
+
result.append(os.path.join(root, f))
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
description="Replay post-commit edits/writes from Claude Code transcripts to recover uncommitted work."
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"roots", nargs="+", help="Root directories containing .jsonl transcripts to scan"
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"-p", "--project", required=True, help="Project name/path substring to match"
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"-r", "--repo-dir", required=True, help="Root directory of the live repository"
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"-c", "--commit-time", required=True, help="Timestamp of last commit (ISO format, e.g., '2026-06-23T17:22:04Z')"
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"-o", "--output", required=True, help="Output directory for replayed files"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
|
|
64
|
+
# Parse commit time
|
|
65
|
+
commit_ms = int(datetime.fromisoformat(args.commit_time.replace("Z", "+00:00")).timestamp() * 1000)
|
|
66
|
+
|
|
67
|
+
output_dir = pathlib.Path(args.output)
|
|
68
|
+
if output_dir.exists():
|
|
69
|
+
import shutil
|
|
70
|
+
shutil.rmtree(output_dir)
|
|
71
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
# Gather .jsonl files
|
|
74
|
+
files = []
|
|
75
|
+
for root in args.roots:
|
|
76
|
+
if os.path.exists(root):
|
|
77
|
+
files.extend(walk_jsonl(root))
|
|
78
|
+
|
|
79
|
+
# Collect Write/Edit/MultiEdit operations
|
|
80
|
+
ops = [] # {rel, ts, tool, input, src}
|
|
81
|
+
for fp in files:
|
|
82
|
+
try:
|
|
83
|
+
with open(fp, "r", encoding="utf-8") as f:
|
|
84
|
+
lines = f.read().split("\n")
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
src = os.path.basename(fp)
|
|
89
|
+
for line in lines:
|
|
90
|
+
if not line.strip():
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
obj = json.loads(line)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
ts = 0
|
|
98
|
+
if obj.get("timestamp"):
|
|
99
|
+
ts = int(datetime.fromisoformat(obj["timestamp"]).timestamp() * 1000)
|
|
100
|
+
|
|
101
|
+
content = obj.get("message", {}).get("content")
|
|
102
|
+
if not isinstance(content, list):
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
for c in content:
|
|
106
|
+
if c.get("type") != "tool_use":
|
|
107
|
+
continue
|
|
108
|
+
if c.get("name") not in ["Write", "Edit", "MultiEdit"]:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
inp = c.get("input") or {}
|
|
112
|
+
file_path = inp.get("file_path") or ""
|
|
113
|
+
file_path_normalized = file_path.replace("\\", "/")
|
|
114
|
+
if f"/{args.project}/" not in file_path_normalized:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
rel = file_path_normalized.split(f"/{args.project}/")[1]
|
|
118
|
+
ops.append(
|
|
119
|
+
{"rel": rel, "ts": ts, "tool": c["name"], "input": inp, "src": src}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
ops.sort(key=lambda x: x["ts"])
|
|
123
|
+
|
|
124
|
+
# Filter post-commit ops
|
|
125
|
+
post = [o for o in ops if o["ts"] > commit_ms]
|
|
126
|
+
|
|
127
|
+
# Group by file
|
|
128
|
+
by_file = {}
|
|
129
|
+
for o in post:
|
|
130
|
+
if o["rel"] not in by_file:
|
|
131
|
+
by_file[o["rel"]] = []
|
|
132
|
+
by_file[o["rel"]].append(o)
|
|
133
|
+
|
|
134
|
+
report = [
|
|
135
|
+
f"# POST-COMMIT REPLAY — {len(post)} uncommitted ops across {len(by_file)} files "
|
|
136
|
+
f"(after {fmt_time(commit_ms)})\n"
|
|
137
|
+
]
|
|
138
|
+
recovered = []
|
|
139
|
+
|
|
140
|
+
for rel in sorted(by_file.keys()):
|
|
141
|
+
ops_list = by_file[rel]
|
|
142
|
+
live_path = os.path.join(args.repo_dir, rel)
|
|
143
|
+
live_ok = os.path.isfile(live_path)
|
|
144
|
+
text = ""
|
|
145
|
+
if live_ok:
|
|
146
|
+
try:
|
|
147
|
+
with open(live_path, "r", encoding="utf-8") as f:
|
|
148
|
+
text = f.read()
|
|
149
|
+
except Exception:
|
|
150
|
+
live_ok = False
|
|
151
|
+
|
|
152
|
+
disk_len = len(text)
|
|
153
|
+
applied = 0
|
|
154
|
+
missed = 0
|
|
155
|
+
miss_details = []
|
|
156
|
+
|
|
157
|
+
for o in ops_list:
|
|
158
|
+
if o["tool"] == "Write":
|
|
159
|
+
text = o["input"].get("content") or text
|
|
160
|
+
applied += 1
|
|
161
|
+
elif o["tool"] == "Edit":
|
|
162
|
+
r = apply_edit(
|
|
163
|
+
text,
|
|
164
|
+
o["input"].get("old_string"),
|
|
165
|
+
o["input"].get("new_string"),
|
|
166
|
+
o["input"].get("replace_all"),
|
|
167
|
+
)
|
|
168
|
+
if isinstance(r, str):
|
|
169
|
+
text = r
|
|
170
|
+
applied += 1
|
|
171
|
+
else:
|
|
172
|
+
missed += 1
|
|
173
|
+
miss_details.append(
|
|
174
|
+
f"{fmt_time(o['ts'])} {(o['input'].get('old_string') or '')[:50].replace(chr(10), '\\n')}"
|
|
175
|
+
)
|
|
176
|
+
elif o["tool"] == "MultiEdit":
|
|
177
|
+
for e in o["input"].get("edits") or []:
|
|
178
|
+
r = apply_edit(
|
|
179
|
+
text,
|
|
180
|
+
e.get("old_string"),
|
|
181
|
+
e.get("new_string"),
|
|
182
|
+
e.get("replace_all"),
|
|
183
|
+
)
|
|
184
|
+
if isinstance(r, str):
|
|
185
|
+
text = r
|
|
186
|
+
applied += 1
|
|
187
|
+
else:
|
|
188
|
+
missed += 1
|
|
189
|
+
miss_details.append(
|
|
190
|
+
f"{fmt_time(o['ts'])} [multi] {(e.get('old_string') or '')[:50].replace(chr(10), '\\n')}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
changed = live_ok and text != (
|
|
194
|
+
open(live_path, "r", encoding="utf-8").read() if live_ok else ""
|
|
195
|
+
)
|
|
196
|
+
out_path = output_dir / rel
|
|
197
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
199
|
+
f.write(text)
|
|
200
|
+
|
|
201
|
+
report.append(f"\n## {rel}")
|
|
202
|
+
report.append(
|
|
203
|
+
f" post-commit ops={len(ops_list)} "
|
|
204
|
+
f"(Write={len([x for x in ops_list if x['tool'] == 'Write'])} "
|
|
205
|
+
f"Edit={len([x for x in ops_list if x['tool'] != 'Write'])}) "
|
|
206
|
+
f"span {fmt_time(ops_list[0]['ts'])} → {fmt_time(ops_list[-1]['ts'])}"
|
|
207
|
+
)
|
|
208
|
+
report.append(
|
|
209
|
+
f" base(disk)={disk_len}b/{count_lines(open(live_path, 'r', encoding='utf-8').read() if live_ok else '')}L "
|
|
210
|
+
f"-> replayed={len(text)}b/{count_lines(text)}L "
|
|
211
|
+
f"| applied={applied} missed={missed} "
|
|
212
|
+
f"| {'*** DIFFERS FROM DISK ***' if changed else 'same as disk'}"
|
|
213
|
+
)
|
|
214
|
+
if miss_details:
|
|
215
|
+
report.append(" MISSED edits (old_string not found in base):")
|
|
216
|
+
for detail in miss_details:
|
|
217
|
+
report.append(f" - {detail}")
|
|
218
|
+
report.append("")
|
|
219
|
+
|
|
220
|
+
if changed:
|
|
221
|
+
recovered.append(rel)
|
|
222
|
+
|
|
223
|
+
report.append(f"\n# FILES WITH RECOVERABLE UNCOMMITTED WORK: {len(recovered)}")
|
|
224
|
+
for r in recovered:
|
|
225
|
+
report.append(f" {r}")
|
|
226
|
+
|
|
227
|
+
report_path = output_dir / "_REPLAY_REPORT.txt"
|
|
228
|
+
with open(report_path, "w", encoding="utf-8") as f:
|
|
229
|
+
f.write("\n".join(report))
|
|
230
|
+
|
|
231
|
+
print("\n".join(report))
|
|
232
|
+
print(f"\noutput dir: {output_dir}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Extract timeline of file changes from Claude Code transcripts (Write/Edit/Read operations)."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fmt_time(ts_ms):
|
|
10
|
+
"""Format timestamp in milliseconds to ISO format with Z suffix."""
|
|
11
|
+
if not ts_ms:
|
|
12
|
+
return "?"
|
|
13
|
+
return datetime.fromtimestamp(ts_ms / 1000).isoformat().replace("T", " ")[:19] + "Z"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def strip_line_nums(text):
|
|
17
|
+
"""Remove line number prefixes (e.g., '123\\t') from tool output."""
|
|
18
|
+
lines = text.split("\n")
|
|
19
|
+
result = []
|
|
20
|
+
for line in lines:
|
|
21
|
+
# Strip pattern: leading spaces + digits + tab
|
|
22
|
+
stripped = line
|
|
23
|
+
if "\t" in line:
|
|
24
|
+
parts = line.split("\t", 1)
|
|
25
|
+
if parts[0].strip().isdigit():
|
|
26
|
+
stripped = parts[1]
|
|
27
|
+
result.append(stripped)
|
|
28
|
+
return "\n".join(result)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def result_text(content):
|
|
32
|
+
"""Extract text from tool result content."""
|
|
33
|
+
if isinstance(content, str):
|
|
34
|
+
return content
|
|
35
|
+
if isinstance(content, list):
|
|
36
|
+
parts = []
|
|
37
|
+
for x in content:
|
|
38
|
+
if isinstance(x, str):
|
|
39
|
+
parts.append(x)
|
|
40
|
+
elif isinstance(x, dict):
|
|
41
|
+
parts.append(x.get("text", ""))
|
|
42
|
+
return "".join(parts)
|
|
43
|
+
if isinstance(content, dict):
|
|
44
|
+
return content.get("text", "")
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def walk_jsonl(directory):
|
|
49
|
+
"""Recursively find all .jsonl files in a directory."""
|
|
50
|
+
result = []
|
|
51
|
+
for root, dirs, files in os.walk(directory):
|
|
52
|
+
for f in files:
|
|
53
|
+
if f.endswith(".jsonl"):
|
|
54
|
+
result.append(os.path.join(root, f))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main():
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
description="Extract timeline of file changes (Write/Edit/Read) from Claude Code transcripts."
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"roots", nargs="+", help="Root directories containing .jsonl transcripts to scan"
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"-p", "--project", required=True, help="Project name/path substring to match"
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"-t", "--targets", nargs="+", required=True, help="Target file paths/substrings to inspect"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
args = parser.parse_args()
|
|
73
|
+
|
|
74
|
+
# Gather .jsonl files
|
|
75
|
+
files = []
|
|
76
|
+
for root in args.roots:
|
|
77
|
+
if os.path.exists(root):
|
|
78
|
+
files.extend(walk_jsonl(root))
|
|
79
|
+
|
|
80
|
+
# Index operations by ID
|
|
81
|
+
use_by_id = {}
|
|
82
|
+
snaps = [] # {rel, ts, size, kind, src, content}
|
|
83
|
+
|
|
84
|
+
for fp in files:
|
|
85
|
+
try:
|
|
86
|
+
with open(fp, "r", encoding="utf-8") as f:
|
|
87
|
+
lines = f.read().split("\n")
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
base = os.path.basename(fp)
|
|
92
|
+
for line in lines:
|
|
93
|
+
if not line.strip():
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
obj = json.loads(line)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
ts = 0
|
|
101
|
+
if obj.get("timestamp"):
|
|
102
|
+
ts = int(datetime.fromisoformat(obj["timestamp"]).timestamp() * 1000)
|
|
103
|
+
|
|
104
|
+
content = obj.get("message", {}).get("content")
|
|
105
|
+
if not isinstance(content, list):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
for c in content:
|
|
109
|
+
if c.get("type") == "tool_use":
|
|
110
|
+
file_path = c.get("input", {}).get("file_path")
|
|
111
|
+
if file_path:
|
|
112
|
+
file_path_normalized = file_path.replace("\\", "/")
|
|
113
|
+
if f"/{args.project}/" in file_path_normalized:
|
|
114
|
+
rel = file_path_normalized.split(f"/{args.project}/")[1]
|
|
115
|
+
else:
|
|
116
|
+
rel = None
|
|
117
|
+
else:
|
|
118
|
+
rel = None
|
|
119
|
+
|
|
120
|
+
if c.get("name") in ["Write", "Edit", "MultiEdit", "Read"]:
|
|
121
|
+
use_by_id[c.get("id")] = {"tool": c.get("name"), "rel": rel, "ts": ts}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
rel
|
|
125
|
+
and c.get("name") == "Write"
|
|
126
|
+
and isinstance(c.get("input", {}).get("content"), str)
|
|
127
|
+
):
|
|
128
|
+
snaps.append(
|
|
129
|
+
{
|
|
130
|
+
"rel": rel,
|
|
131
|
+
"ts": ts,
|
|
132
|
+
"size": len(c["input"]["content"]),
|
|
133
|
+
"kind": "Write",
|
|
134
|
+
"src": base,
|
|
135
|
+
"content": c["input"]["content"],
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
elif c.get("type") == "tool_result":
|
|
140
|
+
u = use_by_id.get(c.get("tool_use_id"))
|
|
141
|
+
if u and u.get("tool") == "Read" and u.get("rel"):
|
|
142
|
+
txt = strip_line_nums(result_text(c.get("content")))
|
|
143
|
+
if txt and len(txt) > 30:
|
|
144
|
+
snaps.append(
|
|
145
|
+
{
|
|
146
|
+
"rel": u["rel"],
|
|
147
|
+
"ts": u.get("ts") or ts,
|
|
148
|
+
"size": len(txt),
|
|
149
|
+
"kind": "Read",
|
|
150
|
+
"src": base,
|
|
151
|
+
"content": txt,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Report on targets
|
|
156
|
+
for target in args.targets:
|
|
157
|
+
target_snaps = [s for s in snaps if s["rel"] and target in s["rel"]]
|
|
158
|
+
target_snaps.sort(key=lambda x: x["ts"])
|
|
159
|
+
|
|
160
|
+
print(f"\n===== {target} ===== ({len(target_snaps)} snapshots)")
|
|
161
|
+
for s in target_snaps:
|
|
162
|
+
print(
|
|
163
|
+
f" {fmt_time(s['ts'])} {str(s['size']).rjust(6)}b "
|
|
164
|
+
f"{s['kind']:<5} {s['src']}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if target_snaps:
|
|
168
|
+
latest = target_snaps[-1]
|
|
169
|
+
largest = sorted(target_snaps, key=lambda x: (-x["size"], -x["ts"]))[0]
|
|
170
|
+
exact = target.split("/")[-1]
|
|
171
|
+
|
|
172
|
+
# Write snapshots
|
|
173
|
+
for snap, tag in [(latest, "LATEST"), (largest, "LARGEST")]:
|
|
174
|
+
if snap is latest and tag == "LARGEST" and snap is latest:
|
|
175
|
+
continue # Skip duplicate
|
|
176
|
+
safe_name = f"{exact}.{tag}".replace(os.sep, "_")
|
|
177
|
+
for c in r'<>:"|?*':
|
|
178
|
+
safe_name = safe_name.replace(c, "_")
|
|
179
|
+
# Write to current dir for simplicity (no output arg in this version)
|
|
180
|
+
print(f" -> {tag}: {snap['size']}b @ {fmt_time(snap['ts'])} ({snap['src']})")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|