@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.
Files changed (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. 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()