@matt82198/aesop 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -24,6 +24,11 @@ from datetime import datetime, timezone
24
24
  from pathlib import Path
25
25
  from time import time
26
26
 
27
+ try:
28
+ from common import check_heartbeat_staleness
29
+ except ImportError:
30
+ from tools.common import check_heartbeat_staleness
31
+
27
32
  # Import UI config for state path resolution
28
33
  UI_DIR = Path(__file__).parent.parent / "ui"
29
34
  if str(UI_DIR) not in sys.path:
@@ -94,31 +99,29 @@ def _check_heartbeat(name, heartbeat_file, threshold=300):
94
99
  Returns:
95
100
  tuple (severity, message) or None if all OK
96
101
  """
97
- if not heartbeat_file.exists():
98
- return ("YELLOW", f"no {name} heartbeat file")
102
+ is_stale, age_seconds, info = check_heartbeat_staleness(heartbeat_file, threshold)
99
103
 
100
- try:
101
- content = heartbeat_file.read_text(encoding="utf-8").strip()
102
- if not content:
103
- return ("YELLOW", f"{name} heartbeat empty")
104
+ if not is_stale:
105
+ return None
104
106
 
105
- try:
106
- timestamp = int(content)
107
- except ValueError:
107
+ # Stale: determine severity and message
108
+ if age_seconds == 0:
109
+ # File missing/empty/unreadable - use generic message
110
+ if "missing" in info.lower():
111
+ return ("YELLOW", f"no {name} heartbeat file")
112
+ elif "empty" in info.lower():
113
+ return ("YELLOW", f"{name} heartbeat empty")
114
+ else:
108
115
  return ("YELLOW", f"{name} heartbeat unparseable")
109
116
 
110
- age_seconds = int(time()) - timestamp
111
- if age_seconds >= threshold * 2:
112
- # Watchdog dead while orchestrator might be active
113
- if name == "watchdog" and age_seconds >= 600:
114
- return ("RED", f"watchdog dead ({age_seconds}s)")
115
- return ("YELLOW", f"{name} stale ({age_seconds}s > {threshold}s threshold)")
116
- elif age_seconds > threshold:
117
- return ("YELLOW", f"{name} stale ({age_seconds}s)")
118
-
119
- return None
120
- except Exception as e:
121
- return ("YELLOW", f"{name} heartbeat read error: {e}")
117
+ # File exists but is stale
118
+ if age_seconds >= threshold * 2:
119
+ # Watchdog dead while orchestrator might be active
120
+ if name == "watchdog" and age_seconds >= 600:
121
+ return ("RED", f"watchdog dead ({age_seconds}s)")
122
+ return ("YELLOW", f"{name} stale ({age_seconds}s > {threshold}s threshold)")
123
+ else:
124
+ return ("YELLOW", f"{name} stale ({age_seconds}s)")
122
125
 
123
126
 
124
127
  def _check_alerts(alerts_file):
@@ -20,13 +20,10 @@ import time
20
20
  from pathlib import Path
21
21
  import argparse
22
22
 
23
-
24
- def get_state_dir():
25
- """Resolve state directory from env var or current working directory."""
26
- if os.environ.get("AESOP_STATE_ROOT"):
27
- return Path(os.environ["AESOP_STATE_ROOT"])
28
- # Default to ./state (relative to cwd)
29
- return Path.cwd() / "state"
23
+ try:
24
+ from common import get_state_dir
25
+ except ImportError:
26
+ from tools.common import get_state_dir
30
27
 
31
28
 
32
29
  def beat(name, status=None, state_dir=None, brain=False):
@@ -54,9 +54,15 @@ class MetricsGate:
54
54
  VERIFICATION_PATTERN = re.compile(r'<!--\s*metrics-verified:\s*[^-]+\s*-->')
55
55
 
56
56
  def __init__(self, diff_range: str = "origin/main...HEAD"):
57
- """Initialize with a git diff range."""
57
+ """Initialize with a git diff range.
58
+
59
+ The gate scans the git repository at the current working directory
60
+ (like git itself), not the directory the script happens to live in.
61
+ In CI it is invoked from the repo root, so behaviour is unchanged;
62
+ this also makes the gate exercisable against any repo under test.
63
+ """
58
64
  self.diff_range = diff_range
59
- self.repo_root = Path(__file__).resolve().parent.parent
65
+ self.repo_root = Path.cwd()
60
66
 
61
67
  def get_diff_lines(self) -> List[Tuple[str, str]]:
62
68
  """Get lines added in diff. Returns list of (file, line_content)."""
@@ -15,15 +15,10 @@ import sys
15
15
  from datetime import datetime, timezone
16
16
  from pathlib import Path
17
17
 
18
-
19
- def get_state_dir():
20
- """Get state directory from env or fallback to AESOP_ROOT/state."""
21
- state_root = os.getenv("AESOP_STATE_ROOT")
22
- if state_root:
23
- return Path(state_root)
24
-
25
- aesop_root = os.getenv("AESOP_ROOT", str(Path.home() / "aesop"))
26
- return Path(aesop_root) / "state"
18
+ try:
19
+ from common import get_state_dir
20
+ except ImportError:
21
+ from tools.common import get_state_dir
27
22
 
28
23
 
29
24
  def set_status(activity, phase, id=None, role=None):
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ reconcile.py — drift detection between STATE.md (git) and state_store (SQLite).
4
+
5
+ ## Why this exists
6
+
7
+ Critique: "SQLite state_store and git-committed STATE.md drift with NO reconcile
8
+ logic." This tool is the conservative first step: detect + report drift by
9
+ default; fix it only with an explicit ``--resolve``.
10
+
11
+ ## What was actually investigated (read STATE.md + state_store/*.py end to end)
12
+
13
+ STATE.md (see ensure_state.py's own template) is unstructured orchestrator
14
+ narrative: Intent / locked decisions / current Phase / NEXT STEPS, written and
15
+ owned by the orchestrator (single-writer, per this project's CLAUDE.md Cardinal
16
+ Rule #7 — "Single-writer: MEMORY.md (keeper), STATE.md (orchestrator)").
17
+
18
+ state_store/ (store.py + api.py + projections.py) is an event-sourced SQLite
19
+ log. Today it registers exactly ONE projection: ``tracker`` — a UI Kanban
20
+ board of backlog items (``id``/``title``/``lane``/``status``/``tags``/
21
+ ``pr_link``/``completed_at``). That projection is a DIFFERENT domain from
22
+ STATE.md: it was one-time-migrated from AUDIT-BACKLOG.md
23
+ (``ui/collectors.py:migrate_tracker_from_backlog``), not from STATE.md, and is
24
+ independently mutated via the UI CRUD endpoints thereafter. There is no
25
+ shared id, key, or semantic concept between STATE.md's prose and a tracker
26
+ item. Reconciling STATE.md against ``tracker`` would mean inventing a mapping
27
+ that does not exist in the codebase's actual design — the task instructions
28
+ explicitly warn against exactly that ("do not invent overlap").
29
+
30
+ **Honest finding: STATE.md and state_store are almost entirely DISJOINT.**
31
+
32
+ The ONE field that genuinely is meant to be "the same fact, told twice" is the
33
+ orchestrator's **current phase/wave identifier** — STATE.md carries it today
34
+ in a structured, reliably-parseable heading (``## Phase: `wave-N-name` ``);
35
+ state_store has no field for it yet (it predates the "additive prototype... a
36
+ later dual-read cutover" state_store/CLAUDE.md describes). Rather than
37
+ pretend a comparable field exists where none does, this tool defines that
38
+ field explicitly — a ``meta`` event stream, ``phase_set`` events, payload
39
+ ``{"phase": <str>}`` — read directly off the generic ``EventStore`` (no
40
+ change to state_store/ itself; any stream name is legal there). Until
41
+ something else populates that stream, real runs will simply show "state_store
42
+ has no phase recorded" every time, which is itself an honest, useful signal:
43
+ it names precisely the gap the critique is pointing at, rather than
44
+ manufacturing a false "in sync".
45
+
46
+ ## Authority
47
+
48
+ - ``phase`` is git-authoritative: STATE.md is the durable, human-reviewed,
49
+ single-writer checkpoint (Cardinal Rule #7); state_store's copy is a mirror
50
+ for read-side consumers (dashboards, MCP, etc.) that would rather not parse
51
+ markdown. ``--resolve`` therefore only ever WRITES to state_store (a new
52
+ ``phase_set`` event) and NEVER writes to STATE.md — the tool must not
53
+ become a second writer of the orchestrator's single-writer file.
54
+ - The field registry (``FIELDS``) and ``decide_resolution`` are generic and
55
+ support a `state_store`-authoritative field the same way, for when
56
+ state_store gains its own independently event-derived facts (e.g. real
57
+ wave-tagged item counts) that STATE.md would want mirrored back — see
58
+ ``test_reconcile.py``'s ``DecideResolutionTest`` for that direction
59
+ exercised in isolation, since no current field actually uses it.
60
+
61
+ ## Usage
62
+
63
+ python tools/reconcile.py --state-md STATE.md --db state/events.db
64
+ python tools/reconcile.py --state-md STATE.md --db state/events.db --resolve
65
+ python tools/reconcile.py --state-md STATE.md --db state/events.db --json
66
+
67
+ Exit codes: 0 = no drift (or drift cleanly resolved), 1 = drift detected and
68
+ NOT resolved (report mode), 2 = usage/file error.
69
+ """
70
+ from __future__ import annotations
71
+
72
+ import argparse
73
+ import json
74
+ import os
75
+ import re
76
+ import sys
77
+ from pathlib import Path
78
+
79
+ ROOT = Path(__file__).resolve().parents[1]
80
+ if str(ROOT) not in sys.path:
81
+ sys.path.insert(0, str(ROOT))
82
+
83
+ from state_store.store import EventStore # noqa: E402
84
+
85
+ PHASE_RE = re.compile(r"^##\s*Phase:\s*`([^`]+)`", re.MULTILINE)
86
+
87
+ META_STREAM = "meta"
88
+ PHASE_EVENT_TYPE = "phase_set"
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Readers (read-only; never create/mutate anything)
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def read_git_phase(state_md_path: str) -> str | None:
96
+ """Parse the current phase identifier out of STATE.md's ``## Phase:`` heading.
97
+
98
+ Returns None if the file has no such heading (or is missing entirely —
99
+ callers that need a hard error on a missing file should stat() first).
100
+ """
101
+ path = Path(state_md_path)
102
+ if not path.exists():
103
+ return None
104
+ content = path.read_text(encoding="utf-8")
105
+ match = PHASE_RE.search(content)
106
+ return match.group(1) if match else None
107
+
108
+
109
+ def read_store_phase(db_path: str) -> str | None:
110
+ """Read the most recent ``phase_set`` event's ``phase`` payload from state_store.
111
+
112
+ Deliberately avoids ever constructing an EventStore against a
113
+ non-existent db_path in report mode: EventStore.__init__ creates the
114
+ sqlite file + tables as a side effect of merely connecting, which would
115
+ make a read-only "detect" invocation leave a footprint on disk. If the
116
+ file does not exist yet, there is by definition no phase recorded there.
117
+ """
118
+ if not db_path or not os.path.exists(db_path):
119
+ return None
120
+ store = EventStore(db_path)
121
+ events = store.read(META_STREAM)
122
+ phase = None
123
+ for ev in events:
124
+ if ev.get("type") == PHASE_EVENT_TYPE:
125
+ payload = ev.get("payload") or {}
126
+ if "phase" in payload:
127
+ phase = payload["phase"]
128
+ return phase
129
+
130
+
131
+ def write_store_phase(db_path: str, phase: str, actor: str = "reconcile") -> int:
132
+ """Append a phase_set event to state_store's meta stream. Returns new version.
133
+
134
+ This is the ONLY write path in this module, and it only ever targets
135
+ state_store (SQLite) — STATE.md (git) is never written by this tool.
136
+ """
137
+ store = EventStore(db_path)
138
+ return store.append(META_STREAM, PHASE_EVENT_TYPE, {"phase": phase}, actor)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Field registry — generic so a future state_store-authoritative field can be
143
+ # added without restructuring the detect/resolve flow.
144
+ # ---------------------------------------------------------------------------
145
+
146
+ FIELDS = {
147
+ "phase": {
148
+ "authority": "git",
149
+ "read_git": read_git_phase,
150
+ "read_store": read_store_phase,
151
+ },
152
+ }
153
+
154
+
155
+ def decide_resolution(git_value, store_value, authority: str):
156
+ """Pure decision: given both current values and which side is authoritative,
157
+ return (drift: bool, target: 'git'|'store'|None, value) where ``target`` is
158
+ which side WOULD need writing to clear the drift (None if no drift, or if
159
+ already in agreement).
160
+
161
+ ``authority`` must be 'git' or 'store'. This function performs no I/O —
162
+ it only decides; callers apply the write.
163
+ """
164
+ if authority not in ("git", "store"):
165
+ raise ValueError(f"unknown authority: {authority!r}")
166
+
167
+ drift = git_value != store_value
168
+ if not drift:
169
+ return (False, None, git_value)
170
+
171
+ if authority == "git":
172
+ return (True, "store", git_value)
173
+ return (True, "git", store_value)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Detect / resolve over the whole registry
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def detect_drift(state_md_path: str, db_path: str) -> dict:
181
+ """Read both sources for every registered field; report drift per field.
182
+
183
+ Read-only: never creates or mutates STATE.md or the state_store db.
184
+ """
185
+ results = []
186
+ any_drift = False
187
+ for name, spec in FIELDS.items():
188
+ git_value = spec["read_git"](state_md_path)
189
+ store_value = spec["read_store"](db_path)
190
+ drift, target, _value = decide_resolution(git_value, store_value, spec["authority"])
191
+ any_drift = any_drift or drift
192
+ results.append({
193
+ "field": name,
194
+ "authority": spec["authority"],
195
+ "git_value": git_value,
196
+ "store_value": store_value,
197
+ "drift": drift,
198
+ "fix_target": target,
199
+ })
200
+ return {"drift": any_drift, "fields": results}
201
+
202
+
203
+ def resolve_drift(state_md_path: str, db_path: str) -> dict:
204
+ """Detect drift, then apply the authoritative value to the non-authoritative
205
+ side for every drifted field. Idempotent: fields already in agreement are
206
+ left untouched (no event appended). Only ever writes to state_store.
207
+
208
+ A field whose authority is 'git' but is drifted is fixed by writing to
209
+ state_store. A field whose authority is 'store' but is drifted would
210
+ require writing to STATE.md — this tool refuses to do that (STATE.md is
211
+ single-writer/orchestrator-owned) and instead reports it unresolved with
212
+ a clear reason, rather than silently corrupting the git checkpoint.
213
+ """
214
+ report = detect_drift(state_md_path, db_path)
215
+ for entry in report["fields"]:
216
+ if not entry["drift"]:
217
+ entry["action"] = "noop"
218
+ continue
219
+ if entry["fix_target"] == "store" and entry["field"] == "phase":
220
+ write_store_phase(db_path, entry["git_value"])
221
+ entry["action"] = "resolved"
222
+ entry["store_value"] = entry["git_value"]
223
+ elif entry["fix_target"] == "git":
224
+ entry["action"] = "refused"
225
+ entry["reason"] = "STATE.md is single-writer (orchestrator); reconcile.py never writes it"
226
+ else:
227
+ entry["action"] = "unsupported"
228
+ report["drift"] = any(e["drift"] and e.get("action") not in ("resolved",) for e in report["fields"])
229
+ return report
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # CLI
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def _format_human(report: dict) -> str:
237
+ lines = []
238
+ for entry in report["fields"]:
239
+ status = "DRIFT" if entry["drift"] else "OK"
240
+ line = (
241
+ f"[{status}] {entry['field']} (authority={entry['authority']}): "
242
+ f"git={entry['git_value']!r} store={entry['store_value']!r}"
243
+ )
244
+ if "action" in entry:
245
+ line += f" action={entry['action']}"
246
+ lines.append(line)
247
+ lines.append("DRIFT DETECTED" if report["drift"] else "no drift")
248
+ return "\n".join(lines)
249
+
250
+
251
+ def main(argv=None) -> int:
252
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
253
+ parser.add_argument("--state-md", default="STATE.md", help="Path to STATE.md (default: ./STATE.md)")
254
+ parser.add_argument("--db", default="state/events.db", help="Path to state_store SQLite db")
255
+ parser.add_argument("--resolve", action="store_true", help="Fix drift by writing the authoritative value to the non-authoritative side (opt-in; default is detect+report only)")
256
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of human text")
257
+ args = parser.parse_args(argv)
258
+
259
+ if not os.path.exists(args.state_md):
260
+ print(f"error: STATE.md not found at {args.state_md}", file=sys.stderr)
261
+ return 2
262
+
263
+ if args.resolve:
264
+ report = resolve_drift(args.state_md, args.db)
265
+ else:
266
+ report = detect_drift(args.state_md, args.db)
267
+
268
+ if args.json:
269
+ print(json.dumps(report, indent=2))
270
+ else:
271
+ print(_format_human(report))
272
+
273
+ return 1 if report["drift"] else 0
274
+
275
+
276
+ if __name__ == "__main__":
277
+ raise SystemExit(main())
@@ -5,6 +5,11 @@ Rotates log files by moving the oldest content to an archive file when the origi
5
5
  exceeds configured thresholds (--max-lines or --max-bytes). Preserves newest lines
6
6
  in the original, ensures no data loss (archive + original == original content).
7
7
 
8
+ Uses advisory file locking to serialize concurrent rotations, and detects/preserves
9
+ external O_APPEND writes (from non-locking writers) that occur during the rotation
10
+ window. This ensures atomicity: no lines are lost even when external processes
11
+ append concurrently.
12
+
8
13
  Exit codes:
9
14
  0: Success (no rotation needed or rotation completed)
10
15
  1: Error (invalid args, I/O failure, etc.)
@@ -17,6 +22,17 @@ import sys
17
22
  from datetime import datetime, timezone
18
23
  from pathlib import Path
19
24
 
25
+ # Platform-specific locking imports
26
+ if sys.platform == 'win32':
27
+ import msvcrt
28
+ HAS_FCNTL = False
29
+ else:
30
+ try:
31
+ import fcntl
32
+ HAS_FCNTL = True
33
+ except ImportError:
34
+ HAS_FCNTL = False
35
+
20
36
 
21
37
  def count_lines(filepath):
22
38
  """Count lines in a file."""
@@ -53,6 +69,34 @@ def write_lines(filepath, lines):
53
69
  raise IOError(f"Failed to write {filepath}: {e}")
54
70
 
55
71
 
72
+ def acquire_lock(fd):
73
+ """Acquire exclusive lock on file descriptor (cross-platform)."""
74
+ if HAS_FCNTL:
75
+ try:
76
+ fcntl.flock(fd, fcntl.LOCK_EX)
77
+ except (AttributeError, TypeError):
78
+ pass
79
+ elif sys.platform == 'win32':
80
+ try:
81
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
82
+ except (OSError, IOError):
83
+ pass # Locking may not be supported on all filesystems
84
+
85
+
86
+ def release_lock(fd):
87
+ """Release exclusive lock on file descriptor (cross-platform)."""
88
+ if HAS_FCNTL:
89
+ try:
90
+ fcntl.flock(fd, fcntl.LOCK_UN)
91
+ except (AttributeError, TypeError):
92
+ pass
93
+ elif sys.platform == 'win32':
94
+ try:
95
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
96
+ except (OSError, IOError):
97
+ pass
98
+
99
+
56
100
  def needs_rotation(filepath, max_lines, max_bytes):
57
101
  """Check if file exceeds any threshold."""
58
102
  current_lines = count_lines(filepath)
@@ -66,7 +110,21 @@ def needs_rotation(filepath, max_lines, max_bytes):
66
110
 
67
111
 
68
112
  def rotate_log(logfile, max_lines, max_bytes, archive_dir, check_only=False):
69
- """Rotate log file by archiving oldest lines.
113
+ """Rotate log file by archiving oldest lines atomically.
114
+
115
+ Uses exclusive file locking to serialize rotations against other lock-takers.
116
+ However, this is an ADVISORY lock: it only protects against other processes
117
+ that voluntarily take the lock. External writers (e.g., shell scripts using >>
118
+ or tee -a) that do NOT take the lock are NOT protected by this mechanism.
119
+
120
+ To prevent data loss from non-locking O_APPEND writers:
121
+ - Lines appended between readlines() and truncate() are detected via file size
122
+ comparison and re-read after truncate+write to prevent loss.
123
+ - This guarantees atomicity: archive + original == original content.
124
+
125
+ NOTE: The advisory lock serializes rotations against each other, but race
126
+ conditions with non-locking O_APPEND writers remain possible. For complete
127
+ safety, all writers (including external processes) must cooperate on locking.
70
128
 
71
129
  Args:
72
130
  logfile: Path to log file to rotate.
@@ -104,69 +162,101 @@ def rotate_log(logfile, max_lines, max_bytes, archive_dir, check_only=False):
104
162
  file=sys.stderr)
105
163
  return 1
106
164
 
107
- # Read all lines
165
+ # Perform rotation with exclusive lock to prevent concurrent write races
108
166
  try:
109
- lines = read_lines(str(logfile))
110
- except IOError as e:
111
- print(f"ERROR: {e}", file=sys.stderr)
112
- return 1
113
-
114
- if not lines:
115
- return 0
116
-
117
- # Determine split point based on which threshold was exceeded
118
- current_lines = len(lines)
119
- current_bytes = sum(len(line.encode('utf-8')) for line in lines)
167
+ with open(str(logfile), 'r+') as f:
168
+ # Acquire exclusive lock to guard read-compute-write sequence
169
+ acquire_lock(f.fileno())
170
+ try:
171
+ # Read all lines while locked
172
+ lines = f.readlines()
173
+
174
+ if not lines:
175
+ return 0
176
+
177
+ # Determine split point based on which threshold was exceeded
178
+ current_lines = len(lines)
179
+ current_bytes = sum(len(line.encode('utf-8')) for line in lines)
180
+
181
+ keep_count = current_lines // 2 # Default: keep ~half
182
+
183
+ # If max_lines exceeded, keep lines just under threshold
184
+ if max_lines and current_lines > max_lines:
185
+ keep_count = min(keep_count, max_lines)
186
+
187
+ # If max_bytes exceeded, calculate how many lines fit under threshold
188
+ if max_bytes and current_bytes > max_bytes:
189
+ cumulative_bytes = 0
190
+ bytes_keep_count = 0
191
+ # Count from newest (end) backward
192
+ for i in range(len(lines) - 1, -1, -1):
193
+ line_bytes = len(lines[i].encode('utf-8'))
194
+ if cumulative_bytes + line_bytes <= max_bytes:
195
+ cumulative_bytes += line_bytes
196
+ bytes_keep_count += 1
197
+ else:
198
+ break
199
+ # Use the more conservative count
200
+ keep_count = min(keep_count, bytes_keep_count)
201
+
202
+ # Guard: ensure we keep at least 1 line, archive the rest
203
+ if keep_count <= 0:
204
+ # If calculated keep_count is 0 or negative, keep at least 1 line
205
+ keep_count = 1
206
+
207
+ # Split lines
208
+ archive_lines = lines[:-keep_count] if keep_count < len(lines) else []
209
+ remaining_lines = lines[-keep_count:] if keep_count > 0 else []
210
+
211
+ if not archive_lines:
212
+ # Nothing to archive
213
+ return 0
214
+
215
+ # Generate archive filename with UTC timestamp
216
+ utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
217
+ archive_filename = f"{logfile.stem}.{utc_now}.log"
218
+ archive_file = archive_path / archive_filename
219
+
220
+ # Write archive file
221
+ try:
222
+ write_lines(str(archive_file), archive_lines)
223
+ except IOError as e:
224
+ print(f"ERROR: {e}", file=sys.stderr)
225
+ return 1
226
+
227
+ # MITIGATION for external O_APPEND writers:
228
+ # Before truncating, check if the file has grown since we initially read it.
229
+ # If external (non-locking) O_APPEND writers added content while we were
230
+ # computing, re-read and preserve those appends.
231
+ original_line_count = len(lines)
232
+
233
+ # Rewind to start and re-count lines to detect external appends
234
+ f.seek(0)
235
+ current_line_count = sum(1 for _ in f)
236
+
237
+ if current_line_count > original_line_count:
238
+ # External appends occurred! Re-read the appended lines
239
+ lines_appended = current_line_count - original_line_count
240
+
241
+ # Re-read from line position (start of appended lines)
242
+ f.seek(0)
243
+ all_current_lines = f.readlines()
244
+
245
+ # Extract only the new lines (the appended ones)
246
+ external_lines = all_current_lines[-lines_appended:]
247
+ if external_lines:
248
+ remaining_lines.extend(external_lines)
249
+
250
+ # Atomically truncate and write remaining lines back to original
251
+ # while still holding the lock
252
+ f.seek(0)
253
+ f.truncate()
254
+ f.writelines(remaining_lines)
255
+
256
+ finally:
257
+ # Release lock after all writes complete
258
+ release_lock(f.fileno())
120
259
 
121
- keep_count = current_lines // 2 # Default: keep ~half
122
-
123
- # If max_lines exceeded, keep lines just under threshold
124
- if max_lines and current_lines > max_lines:
125
- keep_count = min(keep_count, max_lines)
126
-
127
- # If max_bytes exceeded, calculate how many lines fit under threshold
128
- if max_bytes and current_bytes > max_bytes:
129
- cumulative_bytes = 0
130
- bytes_keep_count = 0
131
- # Count from newest (end) backward
132
- for i in range(len(lines) - 1, -1, -1):
133
- line_bytes = len(lines[i].encode('utf-8'))
134
- if cumulative_bytes + line_bytes <= max_bytes:
135
- cumulative_bytes += line_bytes
136
- bytes_keep_count += 1
137
- else:
138
- break
139
- # Use the more conservative count
140
- keep_count = min(keep_count, bytes_keep_count)
141
-
142
- # Guard: ensure we keep at least 1 line, archive the rest
143
- if keep_count <= 0:
144
- # If calculated keep_count is 0 or negative, keep at least 1 line
145
- keep_count = 1
146
-
147
- # Split lines
148
- archive_lines = lines[:-keep_count] if keep_count < len(lines) else []
149
- remaining_lines = lines[-keep_count:] if keep_count > 0 else []
150
-
151
- if not archive_lines:
152
- # Nothing to archive
153
- return 0
154
-
155
- # Generate archive filename with UTC timestamp
156
- utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
157
- archive_filename = f"{logfile.stem}.{utc_now}.log"
158
- archive_file = archive_path / archive_filename
159
-
160
- # Write archive file
161
- try:
162
- write_lines(str(archive_file), archive_lines)
163
- except IOError as e:
164
- print(f"ERROR: {e}", file=sys.stderr)
165
- return 1
166
-
167
- # Write remaining lines back to original
168
- try:
169
- write_lines(str(logfile), remaining_lines)
170
260
  except IOError as e:
171
261
  print(f"ERROR: {e}", file=sys.stderr)
172
262
  return 1