@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,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ci_merge_wait.py — CI-gated merge helper: wait for PR checks to conclude, then merge.
|
|
4
|
+
|
|
5
|
+
Polls gh pr view until all status checks conclude (SUCCESS/FAILURE), then merges ONLY
|
|
6
|
+
if all checks are SUCCESS. The gh pr merge call is STRUCTURALLY UNREACHABLE unless the
|
|
7
|
+
status is SUCCESS — this is the whole point (prevents merge-on-CI-failure edge cases).
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python ci_merge_wait.py <PR-number> [--timeout SECONDS] [--poll SECONDS] [--merge-method merge|squash|rebase]
|
|
11
|
+
[--dry-run] [--self-test]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--timeout SECONDS Max seconds to wait for CI to conclude (default: 3600)
|
|
15
|
+
--poll SECONDS Poll interval in seconds (default: 10)
|
|
16
|
+
--merge-method METHOD Merge strategy: merge, squash, rebase (default: merge)
|
|
17
|
+
--dry-run Skip actual merge, just verify CI status and report what would happen
|
|
18
|
+
--self-test Run offline self-test of polling/decision logic (no network, no PR required)
|
|
19
|
+
|
|
20
|
+
Exit codes:
|
|
21
|
+
0 = PR merged successfully, dry-run verified, or self-test passed
|
|
22
|
+
1 = General error or self-test failed
|
|
23
|
+
2 = CI checks failed (do NOT merge, prints which check failed)
|
|
24
|
+
3 = Timeout waiting for CI to conclude
|
|
25
|
+
4 = PR not mergeable or has merge conflicts
|
|
26
|
+
|
|
27
|
+
Requires: gh CLI available on PATH (unless --self-test). Gracefully exits with error if gh is missing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_gh_command(args):
|
|
38
|
+
"""
|
|
39
|
+
Run gh CLI command; return parsed JSON or None if gh missing/error.
|
|
40
|
+
Raises subprocess.CalledProcessError on non-zero exit.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
args,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=30,
|
|
48
|
+
)
|
|
49
|
+
if result.returncode != 0:
|
|
50
|
+
if "not found" in result.stderr or "No such file" in result.stderr:
|
|
51
|
+
return None
|
|
52
|
+
# Re-raise for non-zero return (will be caught by caller)
|
|
53
|
+
result.check_returncode()
|
|
54
|
+
return json.loads(result.stdout) if result.stdout.strip() else None
|
|
55
|
+
except subprocess.TimeoutExpired:
|
|
56
|
+
print("ERROR: gh command timed out")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
print("ERROR: gh CLI not found on PATH")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_pr_status(pr_number):
|
|
64
|
+
"""
|
|
65
|
+
Fetch PR status via gh pr view.
|
|
66
|
+
Returns dict with 'mergeable' and 'statusCheckRollup' keys.
|
|
67
|
+
Returns None if gh is missing.
|
|
68
|
+
"""
|
|
69
|
+
data = run_gh_command([
|
|
70
|
+
"gh", "pr", "view", str(pr_number),
|
|
71
|
+
"--json", "mergeable,statusCheckRollup"
|
|
72
|
+
])
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_ci_status(status_rollup):
|
|
77
|
+
"""
|
|
78
|
+
Analyze status check rollup.
|
|
79
|
+
Returns: ("pending", None), ("success", None), or ("failure", check_name)
|
|
80
|
+
"""
|
|
81
|
+
if not status_rollup:
|
|
82
|
+
# No checks (unusual but treat as success)
|
|
83
|
+
return ("success", None)
|
|
84
|
+
|
|
85
|
+
# Collect statuses
|
|
86
|
+
pending_checks = []
|
|
87
|
+
failed_checks = []
|
|
88
|
+
|
|
89
|
+
for check in status_rollup:
|
|
90
|
+
status = check.get("status", "").upper()
|
|
91
|
+
if status == "PENDING":
|
|
92
|
+
pending_checks.append(check.get("name", "unknown"))
|
|
93
|
+
elif status == "FAILURE":
|
|
94
|
+
failed_checks.append(check.get("name", "unknown"))
|
|
95
|
+
|
|
96
|
+
# Determine overall status
|
|
97
|
+
if failed_checks:
|
|
98
|
+
return ("failure", failed_checks[0])
|
|
99
|
+
elif pending_checks:
|
|
100
|
+
return ("pending", None)
|
|
101
|
+
else:
|
|
102
|
+
return ("success", None)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def merge_pr(pr_number, merge_method, dry_run=False):
|
|
106
|
+
"""
|
|
107
|
+
Merge the PR using gh pr merge.
|
|
108
|
+
This call is STRUCTURALLY UNREACHABLE unless CI is SUCCESS.
|
|
109
|
+
If dry_run is True, report what would be done without actually merging.
|
|
110
|
+
Returns True on success, False on error.
|
|
111
|
+
"""
|
|
112
|
+
if dry_run:
|
|
113
|
+
print(f"[DRY-RUN] Would merge PR #{pr_number} with --{merge_method}")
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
["gh", "pr", "merge", str(pr_number), f"--{merge_method}"],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
timeout=30,
|
|
121
|
+
)
|
|
122
|
+
return result.returncode == 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_self_test():
|
|
126
|
+
"""
|
|
127
|
+
Run self-test with mocked CI status checks.
|
|
128
|
+
No network calls; verifies the merge guard logic.
|
|
129
|
+
Returns True if all tests pass, False otherwise.
|
|
130
|
+
"""
|
|
131
|
+
print("Running self-test with mocked CI statuses...")
|
|
132
|
+
|
|
133
|
+
# Mock status rollup: all checks SUCCESS
|
|
134
|
+
success_rollup = [
|
|
135
|
+
{"name": "test-unit", "status": "SUCCESS"},
|
|
136
|
+
{"name": "test-integration", "status": "SUCCESS"},
|
|
137
|
+
{"name": "lint", "status": "SUCCESS"},
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
# Test 1: success case
|
|
141
|
+
ci_status, failed_check = check_ci_status(success_rollup)
|
|
142
|
+
if ci_status != "success":
|
|
143
|
+
print(f"FAIL: Expected 'success', got '{ci_status}'")
|
|
144
|
+
return False
|
|
145
|
+
print("[OK] success case: merge guard permits merge")
|
|
146
|
+
|
|
147
|
+
# Test 2: pending case
|
|
148
|
+
pending_rollup = [
|
|
149
|
+
{"name": "test-unit", "status": "PENDING"},
|
|
150
|
+
{"name": "test-integration", "status": "SUCCESS"},
|
|
151
|
+
]
|
|
152
|
+
ci_status, failed_check = check_ci_status(pending_rollup)
|
|
153
|
+
if ci_status != "pending":
|
|
154
|
+
print(f"FAIL: Expected 'pending', got '{ci_status}'")
|
|
155
|
+
return False
|
|
156
|
+
print("[OK] pending case: merge guard blocks merge (structurally unreachable)")
|
|
157
|
+
|
|
158
|
+
# Test 3: failure case
|
|
159
|
+
failure_rollup = [
|
|
160
|
+
{"name": "test-unit", "status": "FAILURE"},
|
|
161
|
+
{"name": "test-integration", "status": "SUCCESS"},
|
|
162
|
+
]
|
|
163
|
+
ci_status, failed_check = check_ci_status(failure_rollup)
|
|
164
|
+
if ci_status != "failure" or failed_check != "test-unit":
|
|
165
|
+
print(f"FAIL: Expected 'failure' with 'test-unit', got '{ci_status}' / '{failed_check}'")
|
|
166
|
+
return False
|
|
167
|
+
print("[OK] failure case: merge guard blocks merge (structurally unreachable)")
|
|
168
|
+
|
|
169
|
+
# Test 4: no checks (treat as success)
|
|
170
|
+
ci_status, _ = check_ci_status([])
|
|
171
|
+
if ci_status != "success":
|
|
172
|
+
print(f"FAIL: Expected 'success' for no checks, got '{ci_status}'")
|
|
173
|
+
return False
|
|
174
|
+
print("[OK] no-checks case: treated as success")
|
|
175
|
+
|
|
176
|
+
print("\nAll self-tests passed!")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main():
|
|
181
|
+
parser = argparse.ArgumentParser(
|
|
182
|
+
description=__doc__,
|
|
183
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"pr_number",
|
|
187
|
+
type=int,
|
|
188
|
+
nargs="?",
|
|
189
|
+
default=None,
|
|
190
|
+
help="GitHub PR number (required unless using --self-test)"
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--timeout",
|
|
194
|
+
type=int,
|
|
195
|
+
default=3600,
|
|
196
|
+
help="Max seconds to wait for CI (default: 3600)"
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"--poll",
|
|
200
|
+
type=int,
|
|
201
|
+
default=10,
|
|
202
|
+
help="Poll interval in seconds (default: 10)"
|
|
203
|
+
)
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
"--merge-method",
|
|
206
|
+
choices=["merge", "squash", "rebase"],
|
|
207
|
+
default="merge",
|
|
208
|
+
help="Merge strategy (default: merge)"
|
|
209
|
+
)
|
|
210
|
+
parser.add_argument(
|
|
211
|
+
"--dry-run",
|
|
212
|
+
action="store_true",
|
|
213
|
+
help="Skip actual merge, just verify CI status and report what would happen"
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--self-test",
|
|
217
|
+
action="store_true",
|
|
218
|
+
help="Run offline self-test of polling/decision logic (no network)"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
args = parser.parse_args()
|
|
222
|
+
|
|
223
|
+
# Handle self-test mode
|
|
224
|
+
if args.self_test:
|
|
225
|
+
if run_self_test():
|
|
226
|
+
sys.exit(0)
|
|
227
|
+
else:
|
|
228
|
+
sys.exit(1)
|
|
229
|
+
|
|
230
|
+
# Validate PR is provided for non-self-test mode
|
|
231
|
+
if args.pr_number is None:
|
|
232
|
+
print("ERROR: PR number is required (unless using --self-test)")
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
# Validate inputs
|
|
236
|
+
if args.pr_number <= 0:
|
|
237
|
+
print("ERROR: PR number must be positive")
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
if args.timeout <= 0 or args.poll <= 0:
|
|
241
|
+
print("ERROR: timeout and poll must be positive")
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
# Fetch initial PR status
|
|
245
|
+
print(f"Checking PR #{args.pr_number} status...")
|
|
246
|
+
status = get_pr_status(args.pr_number)
|
|
247
|
+
if status is None:
|
|
248
|
+
print("ERROR: gh CLI not available or PR not found")
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
|
|
251
|
+
mergeable = status.get("mergeable", "").upper()
|
|
252
|
+
if mergeable == "CONFLICTED":
|
|
253
|
+
print(f"MERGE CONFLICT: PR #{args.pr_number} has conflicts")
|
|
254
|
+
sys.exit(4)
|
|
255
|
+
elif mergeable not in ("MERGEABLE", "UNKNOWN"):
|
|
256
|
+
# Also treat UNKNOWN conservatively as non-mergeable until we have clarity
|
|
257
|
+
print(f"NOT MERGEABLE: PR #{args.pr_number} status={mergeable}")
|
|
258
|
+
sys.exit(4)
|
|
259
|
+
|
|
260
|
+
# Poll until CI concludes
|
|
261
|
+
start_time = time.time()
|
|
262
|
+
while True:
|
|
263
|
+
elapsed = time.time() - start_time
|
|
264
|
+
if elapsed > args.timeout:
|
|
265
|
+
print(f"TIMEOUT: CI did not conclude within {args.timeout}s")
|
|
266
|
+
sys.exit(3)
|
|
267
|
+
|
|
268
|
+
status = get_pr_status(args.pr_number)
|
|
269
|
+
if status is None:
|
|
270
|
+
print("ERROR: gh CLI check failed")
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
status_rollup = status.get("statusCheckRollup", [])
|
|
274
|
+
ci_status, failed_check = check_ci_status(status_rollup)
|
|
275
|
+
|
|
276
|
+
if ci_status == "failure":
|
|
277
|
+
print(f"CI FAILED: {failed_check}")
|
|
278
|
+
sys.exit(2)
|
|
279
|
+
elif ci_status == "success":
|
|
280
|
+
# SUCCESS: CI is green, re-check immediately before proceeding
|
|
281
|
+
print(f"CI GREEN: All checks passed. Re-checking status before merge...")
|
|
282
|
+
final_check = get_pr_status(args.pr_number)
|
|
283
|
+
if final_check is None:
|
|
284
|
+
print("ERROR: final status check failed")
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
final_rollup = final_check.get("statusCheckRollup", [])
|
|
288
|
+
final_ci_status, final_failed = check_ci_status(final_rollup)
|
|
289
|
+
|
|
290
|
+
if final_ci_status != "success":
|
|
291
|
+
print(f"CI STATUS CHANGED: {final_failed}")
|
|
292
|
+
sys.exit(2)
|
|
293
|
+
|
|
294
|
+
# SUCCESS: CI is still green, proceed to merge
|
|
295
|
+
# This merge call is STRUCTURALLY UNREACHABLE unless ci_status == "success"
|
|
296
|
+
if args.dry_run:
|
|
297
|
+
print(f"[DRY-RUN] PR #{args.pr_number} CI is green, would merge...")
|
|
298
|
+
else:
|
|
299
|
+
print(f"CI CONFIRMED GREEN. Merging PR #{args.pr_number}...")
|
|
300
|
+
|
|
301
|
+
if merge_pr(args.pr_number, args.merge_method, dry_run=args.dry_run):
|
|
302
|
+
if args.dry_run:
|
|
303
|
+
print(f"[DRY-RUN] PR #{args.pr_number} merge command would succeed")
|
|
304
|
+
else:
|
|
305
|
+
print(f"MERGED: PR #{args.pr_number} merged successfully")
|
|
306
|
+
sys.exit(0)
|
|
307
|
+
else:
|
|
308
|
+
print("ERROR: merge failed (PR state changed?)")
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
|
|
311
|
+
# Still pending: wait and retry
|
|
312
|
+
print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
|
|
313
|
+
time.sleep(args.poll)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Scaffold durable checkpointing directories for project orchestration.
|
|
4
|
+
|
|
5
|
+
Usage: ensure_state.py --state-dir DIR
|
|
6
|
+
|
|
7
|
+
Creates STATE.md and BUILDLOG.md templates in the state directory
|
|
8
|
+
if they do not already exist. Never overwrites existing files.
|
|
9
|
+
"""
|
|
10
|
+
# secretscan: allow-pattern-docs
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import os
|
|
14
|
+
import argparse
|
|
15
|
+
import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
STATE_TEMPLATE = """# STATE — authoritative project checkpoint
|
|
20
|
+
|
|
21
|
+
## Intent
|
|
22
|
+
One-line summary of the project's current phase and goal.
|
|
23
|
+
|
|
24
|
+
## Stack & locked decisions
|
|
25
|
+
- Key technology choices and constraints.
|
|
26
|
+
- Data model contracts and API signatures.
|
|
27
|
+
|
|
28
|
+
## Current status
|
|
29
|
+
- Phase summary and completion %.
|
|
30
|
+
- Major blockers or decisions pending.
|
|
31
|
+
|
|
32
|
+
## Gotchas
|
|
33
|
+
- Known issues, workarounds, environment quirks.
|
|
34
|
+
|
|
35
|
+
## NEXT STEPS
|
|
36
|
+
- Explicit ordered list of what comes next.
|
|
37
|
+
- Assigned owners if coordinating multiple agents.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
BUILDLOG_HEADER = "# BUILDLOG — append-only progress log"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_state_files(state_dir):
|
|
44
|
+
"""
|
|
45
|
+
Create state directory with STATE.md and BUILDLOG.md if missing.
|
|
46
|
+
Returns list of (filename, status) tuples: ('STATE.md', 'CREATED'), etc.
|
|
47
|
+
"""
|
|
48
|
+
state_path = Path(state_dir)
|
|
49
|
+
state_path.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
results = []
|
|
52
|
+
|
|
53
|
+
# STATE.md
|
|
54
|
+
state_file = state_path / 'STATE.md'
|
|
55
|
+
if state_file.exists():
|
|
56
|
+
results.append(('STATE.md', 'EXISTS'))
|
|
57
|
+
else:
|
|
58
|
+
with open(state_file, 'w', encoding='utf-8') as f:
|
|
59
|
+
f.write(STATE_TEMPLATE)
|
|
60
|
+
results.append(('STATE.md', 'CREATED'))
|
|
61
|
+
|
|
62
|
+
# BUILDLOG.md
|
|
63
|
+
buildlog_file = state_path / 'BUILDLOG.md'
|
|
64
|
+
if buildlog_file.exists():
|
|
65
|
+
results.append(('BUILDLOG.md', 'EXISTS'))
|
|
66
|
+
else:
|
|
67
|
+
timestamp = datetime.datetime.now().isoformat()
|
|
68
|
+
with open(buildlog_file, 'w', encoding='utf-8') as f:
|
|
69
|
+
f.write(f'{BUILDLOG_HEADER}\n')
|
|
70
|
+
f.write(f'created {timestamp}\n')
|
|
71
|
+
results.append(('BUILDLOG.md', 'CREATED'))
|
|
72
|
+
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main():
|
|
77
|
+
parser = argparse.ArgumentParser(
|
|
78
|
+
description='Scaffold durable checkpointing directories.'
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument('--state-dir', required=True,
|
|
81
|
+
help='State directory')
|
|
82
|
+
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
|
|
85
|
+
state_dir = args.state_dir
|
|
86
|
+
|
|
87
|
+
results = ensure_state_files(state_dir)
|
|
88
|
+
|
|
89
|
+
for filename, status in results:
|
|
90
|
+
print(f'{status} {filename}')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == '__main__':
|
|
94
|
+
main()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
eod_sweep.py — End-of-day safety check for repository health.
|
|
4
|
+
|
|
5
|
+
Verifies git repositories are safe (no data loss risk):
|
|
6
|
+
- Working tree clean/dirty
|
|
7
|
+
- Branch pushed (ahead-count 0)
|
|
8
|
+
- Untracked files not in .gitignore
|
|
9
|
+
|
|
10
|
+
Output contract:
|
|
11
|
+
Line 1: EOD-SWEEP: SAFE or EOD-SWEEP: AT-RISK — <n> findings
|
|
12
|
+
Lines 2+: One finding per line (if any)
|
|
13
|
+
Exit code 0 only when SAFE.
|
|
14
|
+
|
|
15
|
+
Usage: eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]
|
|
16
|
+
|
|
17
|
+
--repos: Colon-separated paths to scan (default: empty; use env var or flag to specify)
|
|
18
|
+
--readonly-repos: Colon-separated paths that should NOT be auto-pushed
|
|
19
|
+
--fix-push: Auto-push unpushed commits in repos where safe
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Finding:
|
|
31
|
+
"""A single finding with repo + message."""
|
|
32
|
+
def __init__(self, repo, msg):
|
|
33
|
+
self.repo = repo
|
|
34
|
+
self.msg = msg
|
|
35
|
+
|
|
36
|
+
def __str__(self):
|
|
37
|
+
return f"{self.repo.name}: {self.msg}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_git_status(repo_path):
|
|
41
|
+
"""Return (is_clean, dirty_files_list) for a repo."""
|
|
42
|
+
try:
|
|
43
|
+
output = subprocess.run(
|
|
44
|
+
['git', '-C', str(repo_path), 'status', '--porcelain'],
|
|
45
|
+
capture_output=True, text=True, timeout=5
|
|
46
|
+
).stdout.strip()
|
|
47
|
+
|
|
48
|
+
if not output:
|
|
49
|
+
return (True, [])
|
|
50
|
+
else:
|
|
51
|
+
dirty = [line for line in output.split('\n') if line]
|
|
52
|
+
return (False, dirty)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return (None, str(e))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_ahead_count(repo_path):
|
|
58
|
+
"""Return count of commits ahead of origin/HEAD (or None on error)."""
|
|
59
|
+
try:
|
|
60
|
+
# First check if there's a tracking branch
|
|
61
|
+
try:
|
|
62
|
+
output = subprocess.run(
|
|
63
|
+
['git', '-C', str(repo_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
|
|
64
|
+
capture_output=True, text=True, timeout=5
|
|
65
|
+
).stdout.strip()
|
|
66
|
+
except:
|
|
67
|
+
# Fallback to origin/HEAD if no upstream
|
|
68
|
+
output = subprocess.run(
|
|
69
|
+
['git', '-C', str(repo_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
|
|
70
|
+
capture_output=True, text=True, timeout=5
|
|
71
|
+
).stdout.strip()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return int(output) if output else 0
|
|
75
|
+
except:
|
|
76
|
+
return None
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_untracked_files(repo_path):
|
|
82
|
+
"""Return list of untracked files not in .gitignore."""
|
|
83
|
+
try:
|
|
84
|
+
output = subprocess.run(
|
|
85
|
+
['git', '-C', str(repo_path), 'ls-files', '--others', '--exclude-standard'],
|
|
86
|
+
capture_output=True, text=True, timeout=5
|
|
87
|
+
).stdout.strip()
|
|
88
|
+
|
|
89
|
+
if output:
|
|
90
|
+
return output.split('\n')
|
|
91
|
+
return []
|
|
92
|
+
except Exception:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_repo(repo_path):
|
|
97
|
+
"""Check a single repo; return list of Finding objects or None if repo doesn't exist."""
|
|
98
|
+
if not repo_path.exists():
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
if not (repo_path / '.git').exists():
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
findings = []
|
|
105
|
+
|
|
106
|
+
# Check 1: Working tree clean
|
|
107
|
+
is_clean, dirty = get_git_status(repo_path)
|
|
108
|
+
if is_clean is None:
|
|
109
|
+
findings.append(Finding(repo_path, f"git status check failed: {dirty}"))
|
|
110
|
+
elif not is_clean:
|
|
111
|
+
findings.append(Finding(repo_path, f"dirty working tree: {len(dirty)} files"))
|
|
112
|
+
|
|
113
|
+
# Check 2: Branch pushed
|
|
114
|
+
ahead = get_ahead_count(repo_path)
|
|
115
|
+
if ahead is None:
|
|
116
|
+
findings.append(Finding(repo_path, "ahead-count check failed"))
|
|
117
|
+
elif ahead > 0:
|
|
118
|
+
findings.append(Finding(repo_path, f"ahead of origin: {ahead} commits unpushed"))
|
|
119
|
+
|
|
120
|
+
# Check 3: Untracked files
|
|
121
|
+
untracked = check_untracked_files(repo_path)
|
|
122
|
+
if untracked is None:
|
|
123
|
+
findings.append(Finding(repo_path, "untracked file check failed"))
|
|
124
|
+
elif untracked:
|
|
125
|
+
findings.append(Finding(repo_path, f"untracked files: {len(untracked)} items"))
|
|
126
|
+
|
|
127
|
+
return findings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def push_repo(repo_path):
|
|
131
|
+
"""Push commits for a repo (return True if successful)."""
|
|
132
|
+
try:
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
['git', '-C', str(repo_path), 'push'],
|
|
135
|
+
capture_output=True, text=True, timeout=30
|
|
136
|
+
)
|
|
137
|
+
return result.returncode == 0
|
|
138
|
+
except Exception:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_secret_scan(repo_path):
|
|
143
|
+
"""Run secret_scan.py on staged files (return True if no secrets found)."""
|
|
144
|
+
try:
|
|
145
|
+
script_path = Path(__file__).parent / 'secret_scan.py'
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
[sys.executable, str(script_path), '--staged'],
|
|
148
|
+
cwd=str(repo_path),
|
|
149
|
+
capture_output=True, text=True, timeout=30
|
|
150
|
+
)
|
|
151
|
+
return result.returncode == 0
|
|
152
|
+
except Exception:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main():
|
|
157
|
+
import argparse
|
|
158
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
'--repos',
|
|
161
|
+
default='',
|
|
162
|
+
help='Colon-separated paths to scan (default: empty)'
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
'--readonly-repos',
|
|
166
|
+
default='',
|
|
167
|
+
help='Colon-separated paths that should NOT be auto-pushed'
|
|
168
|
+
)
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
'--fix-push',
|
|
171
|
+
action='store_true',
|
|
172
|
+
help='Auto-push unpushed commits'
|
|
173
|
+
)
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
# Parse repos
|
|
177
|
+
repos_to_check = []
|
|
178
|
+
if args.repos:
|
|
179
|
+
repos_to_check = [Path(p) for p in args.repos.split(':') if p]
|
|
180
|
+
|
|
181
|
+
# Parse readonly repos
|
|
182
|
+
readonly_repos = set()
|
|
183
|
+
if args.readonly_repos:
|
|
184
|
+
readonly_repos = {Path(p) for p in args.readonly_repos.split(':') if p}
|
|
185
|
+
|
|
186
|
+
findings = []
|
|
187
|
+
|
|
188
|
+
# Scan all repos
|
|
189
|
+
for repo_path in repos_to_check:
|
|
190
|
+
repo_findings = check_repo(repo_path)
|
|
191
|
+
if repo_findings is not None:
|
|
192
|
+
findings.extend(repo_findings)
|
|
193
|
+
|
|
194
|
+
# Determine verdict
|
|
195
|
+
if not findings:
|
|
196
|
+
verdict = "SAFE"
|
|
197
|
+
verdict_line = "EOD-SWEEP: SAFE"
|
|
198
|
+
exit_code = 0
|
|
199
|
+
else:
|
|
200
|
+
verdict = f"AT-RISK — {len(findings)} findings"
|
|
201
|
+
verdict_line = f"EOD-SWEEP: AT-RISK — {len(findings)} findings"
|
|
202
|
+
exit_code = 1
|
|
203
|
+
|
|
204
|
+
# Handle --fix-push if requested and conditions are met
|
|
205
|
+
if args.fix_push and findings:
|
|
206
|
+
# Filter for ahead-only findings that we can push
|
|
207
|
+
ahead_findings = [f for f in findings if 'unpushed' in f.msg and f.repo not in readonly_repos]
|
|
208
|
+
if ahead_findings:
|
|
209
|
+
for finding in ahead_findings:
|
|
210
|
+
repo_path = finding.repo
|
|
211
|
+
if repo_path and repo_path not in readonly_repos:
|
|
212
|
+
# Run secret scan first
|
|
213
|
+
if run_secret_scan(repo_path):
|
|
214
|
+
if push_repo(repo_path):
|
|
215
|
+
print(f"Pushed: {repo_path.name}")
|
|
216
|
+
findings.remove(finding)
|
|
217
|
+
else:
|
|
218
|
+
print(f"Push failed: {repo_path.name}")
|
|
219
|
+
else:
|
|
220
|
+
print(f"Secret scan blocked: {repo_path.name}")
|
|
221
|
+
|
|
222
|
+
# Re-evaluate verdict
|
|
223
|
+
if not findings:
|
|
224
|
+
verdict = "SAFE"
|
|
225
|
+
verdict_line = "EOD-SWEEP: SAFE"
|
|
226
|
+
exit_code = 0
|
|
227
|
+
else:
|
|
228
|
+
verdict_line = f"EOD-SWEEP: AT-RISK — {len(findings)} findings"
|
|
229
|
+
|
|
230
|
+
# Print output
|
|
231
|
+
print(verdict_line)
|
|
232
|
+
for finding in findings:
|
|
233
|
+
print(f" {finding}")
|
|
234
|
+
|
|
235
|
+
sys.exit(exit_code)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == '__main__':
|
|
239
|
+
main()
|