@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -4,6 +4,7 @@ Automated silent-hang detection for agent transcripts.
4
4
 
5
5
  Usage:
6
6
  stall_check.py [--transcripts-root DIR] [--threshold-seconds SEC] [--json] [--exit-nonzero-on-stall]
7
+ [--active-from DIR] [--emit-recovery] [--recovery-dir DIR]
7
8
 
8
9
  Options:
9
10
  --transcripts-root DIR Root directory to scan for agent-*.jsonl transcripts.
@@ -12,11 +13,24 @@ Options:
12
13
  Transcripts older than this are flagged as stalled.
13
14
  --json Output as JSON list of {agent_id, age_seconds, stalled, last_mtime}.
14
15
  --exit-nonzero-on-stall Exit 1 if any agent is detected as stalled; default exit 0 always.
16
+ --active-from DIR Optional. When set, an agent is ACTIVE only if a matching task/status
17
+ file exists in DIR (named <agent_id>.task or <agent_id>.status).
18
+ STALLED = stale mtime AND active. Default behavior (no flag):
19
+ STALLED = stale mtime only.
20
+ --emit-recovery When set, emit recovery advisory JSON for each STALLED agent.
21
+ Advisory includes: agent, verdict, age_s, suggested_action (list).
22
+ Output as JSON blocks (one per STALLED agent).
23
+ --recovery-dir DIR Optional; only used with --emit-recovery. When set, write one
24
+ recovery-<agent>.json file per STALLED agent to DIR (idempotent).
25
+ No files written when none stalled.
15
26
 
16
27
  Behavior:
17
28
  - Walks transcripts-root for files matching agent-*.jsonl.
18
29
  - For each file, computes age = now - file mtime (seconds).
19
- - Reports agents as stalled if age > threshold-seconds.
30
+ - Without --active-from: Reports agents as stalled if age > threshold-seconds (legacy).
31
+ - With --active-from: Reports agents as stalled if age > threshold-seconds AND active file exists.
32
+ - With --emit-recovery: Emits JSON advisory for each STALLED agent (to stdout).
33
+ - With --recovery-dir: Additionally writes recovery-<agent>.json files (idempotent, overwrite).
20
34
  - Default output: human-readable table; add --json for structured output.
21
35
  - Gracefully reports "no transcripts found" if root is missing or empty.
22
36
  - Exit code: 0 always (unless --exit-nonzero-on-stall specified and stalls detected).
@@ -27,9 +41,50 @@ import os
27
41
  import time
28
42
  import json
29
43
  import argparse
44
+ import re
30
45
  from pathlib import Path
31
46
 
32
47
 
48
+ def sanitize_agent_id(agent_id, filename):
49
+ """Validate and sanitize agent ID to prevent path traversal (CWE-22).
50
+
51
+ Args:
52
+ agent_id: Extracted agent ID from filename
53
+ filename: Original filename for warning message
54
+
55
+ Returns:
56
+ Sanitized agent_id if valid, None if invalid (caller should skip entry)
57
+ """
58
+ # Allowlist: only alphanumeric, underscore, dash
59
+ if not re.match(r"^[A-Za-z0-9_-]+$", agent_id):
60
+ # Log warning with filename only, never echo raw agent_id into a path
61
+ sys.stderr.write(f"Warning: Skipping transcript with invalid agent ID in file: {filename}\n")
62
+ return None
63
+ return agent_id
64
+
65
+
66
+ def verify_path_containment(resolved_path, base_dir):
67
+ """Verify that a path is contained within a base directory (defense-in-depth).
68
+
69
+ Args:
70
+ resolved_path: Resolved absolute path to verify
71
+ base_dir: Base directory that path must be contained in
72
+
73
+ Returns:
74
+ True if path is safely contained, False if escape attempted
75
+ """
76
+ # Resolve BOTH sides: an unresolved 8.3 short-form candidate compared
77
+ # against a resolved long-form base raises ValueError and reads as an
78
+ # escape (windows-runner regression: stale+active reported ok).
79
+ resolved_base = Path(base_dir).resolve()
80
+ try:
81
+ Path(resolved_path).resolve().relative_to(resolved_base)
82
+ return True
83
+ except ValueError:
84
+ # Path is outside base_dir
85
+ return False
86
+
87
+
33
88
  def get_transcripts_root():
34
89
  """Resolve transcripts root from env var or default to ~/.claude/projects."""
35
90
  if os.environ.get("AESOP_TRANSCRIPTS_ROOT"):
@@ -38,10 +93,44 @@ def get_transcripts_root():
38
93
  return Path.home() / ".claude" / "projects"
39
94
 
40
95
 
41
- def scan_transcripts(transcripts_root, threshold_seconds):
96
+ def is_agent_active(agent_id, active_from_dir):
97
+ """Check if an agent is active by looking for task/status files.
98
+
99
+ Args:
100
+ agent_id: Agent identifier (e.g., 'abc123') - must be pre-sanitized
101
+ active_from_dir: Directory to scan for <agent_id>.task or <agent_id>.status files
102
+
103
+ Returns:
104
+ True if any matching task/status file exists, False otherwise.
105
+ """
106
+ if not active_from_dir:
107
+ return None # Flag not provided
108
+
109
+ active_from_path = Path(active_from_dir)
110
+ if not active_from_path.exists():
111
+ return False
112
+
113
+ # Check for <agent_id>.task or <agent_id>.status files with containment verification
114
+ for pattern in [f"{agent_id}.task", f"{agent_id}.status"]:
115
+ candidate_path = (active_from_path / pattern)
116
+ # Defense-in-depth: verify path is contained in active_from_path
117
+ if verify_path_containment(candidate_path, active_from_path):
118
+ if candidate_path.exists():
119
+ return True
120
+
121
+ return False
122
+
123
+
124
+ def scan_transcripts(transcripts_root, threshold_seconds, active_from_dir=None):
42
125
  """Scan transcripts root for agent-*.jsonl files and compute staleness.
43
126
 
44
- Returns: list of dicts {agent, transcript, mtime_age_s, verdict, suggested_action, last_mtime (ISO)}
127
+ Args:
128
+ transcripts_root: Root directory to scan
129
+ threshold_seconds: Staleness threshold in seconds
130
+ active_from_dir: Optional. When set, agent is ACTIVE only if task/status file exists.
131
+ STALLED = stale mtime AND active. Default (None): STALLED = stale mtime only.
132
+
133
+ Returns: list of dicts {agent, transcript, mtime_age_s, verdict, suggested_action, last_mtime (ISO), active}
45
134
  """
46
135
  transcripts_root = Path(transcripts_root)
47
136
 
@@ -66,16 +155,35 @@ def scan_transcripts(transcripts_root, threshold_seconds):
66
155
  # Extract agent_id from filename (e.g., agent-abc123.jsonl -> abc123)
67
156
  agent_id = jsonl_file.stem.replace("agent-", "")
68
157
 
158
+ # Sanitize agent_id to prevent path traversal (CWE-22)
159
+ agent_id = sanitize_agent_id(agent_id, str(jsonl_file))
160
+ if agent_id is None:
161
+ # Invalid agent_id, skip this entry
162
+ continue
163
+
164
+ # Check active status if flag is provided
165
+ active = is_agent_active(agent_id, active_from_dir)
166
+
69
167
  # Determine verdict
70
168
  if age_seconds <= STALE_THRESHOLD:
71
169
  verdict = "ok"
72
170
  suggested_action = None
73
171
  elif age_seconds <= DEAD_THRESHOLD:
74
- verdict = "stale"
75
- suggested_action = "monitor for progress or investigate why transcript is stalled"
172
+ # If active_from_dir is set, only stale if also active
173
+ if active_from_dir is not None and not active:
174
+ verdict = "ok"
175
+ suggested_action = None
176
+ else:
177
+ verdict = "stale"
178
+ suggested_action = "monitor for progress or investigate why transcript is stalled"
76
179
  else:
77
- verdict = "dead"
78
- suggested_action = "investigate immediately; agent may be hung or crashed"
180
+ # If active_from_dir is set, only dead if also active
181
+ if active_from_dir is not None and not active:
182
+ verdict = "ok"
183
+ suggested_action = None
184
+ else:
185
+ verdict = "dead"
186
+ suggested_action = "investigate immediately; agent may be hung or crashed"
79
187
 
80
188
  results.append({
81
189
  "agent": agent_id,
@@ -84,11 +192,107 @@ def scan_transcripts(transcripts_root, threshold_seconds):
84
192
  "verdict": verdict,
85
193
  "suggested_action": suggested_action,
86
194
  "last_mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mtime)),
195
+ "active": active if active_from_dir is not None else None,
87
196
  })
88
197
 
89
198
  return results
90
199
 
91
200
 
201
+ def emit_recovery_advisories(results):
202
+ """Emit recovery advisory JSON blocks for stalled agents.
203
+
204
+ Yields JSON strings (one per stalled agent) with advisory structure:
205
+ {
206
+ "agent": <agent_id>,
207
+ "verdict": <verdict>,
208
+ "age_s": <seconds>,
209
+ "suggested_action": [<ordered list of actions>]
210
+ }
211
+ """
212
+ if not results:
213
+ return
214
+
215
+ stalled_entries = [r for r in results if r["verdict"] in ("stale", "dead")]
216
+
217
+ for entry in stalled_entries:
218
+ # Build ordered list of suggested actions
219
+ actions = []
220
+
221
+ if entry["verdict"] == "stale":
222
+ actions.append("SendMessage resume with scope recap")
223
+ actions.append("TaskStop + relaunch from journal")
224
+ actions.append("inspect transcript tail")
225
+ elif entry["verdict"] == "dead":
226
+ actions.append("TaskStop immediately")
227
+ actions.append("inspect transcript for crash/error")
228
+ actions.append("relaunch from last known-good checkpoint")
229
+
230
+ advisory = {
231
+ "agent": entry["agent"],
232
+ "verdict": entry["verdict"],
233
+ "age_s": entry["mtime_age_s"],
234
+ "suggested_action": actions,
235
+ }
236
+
237
+ yield json.dumps(advisory)
238
+
239
+
240
+ def write_recovery_files(results, recovery_dir):
241
+ """Write recovery-<agent>.json files for each stalled agent (idempotent).
242
+
243
+ Args:
244
+ results: List of scan results
245
+ recovery_dir: Directory to write recovery files to
246
+
247
+ Returns:
248
+ Count of files written
249
+ """
250
+ if not results or not recovery_dir:
251
+ return 0
252
+
253
+ recovery_path = Path(recovery_dir)
254
+ recovery_path.mkdir(parents=True, exist_ok=True)
255
+
256
+ files_written = 0
257
+ stalled_entries = [r for r in results if r["verdict"] in ("stale", "dead")]
258
+
259
+ for entry in stalled_entries:
260
+ # Build ordered list of suggested actions
261
+ actions = []
262
+
263
+ if entry["verdict"] == "stale":
264
+ actions.append("SendMessage resume with scope recap")
265
+ actions.append("TaskStop + relaunch from journal")
266
+ actions.append("inspect transcript tail")
267
+ elif entry["verdict"] == "dead":
268
+ actions.append("TaskStop immediately")
269
+ actions.append("inspect transcript for crash/error")
270
+ actions.append("relaunch from last known-good checkpoint")
271
+
272
+ advisory = {
273
+ "agent": entry["agent"],
274
+ "verdict": entry["verdict"],
275
+ "age_s": entry["mtime_age_s"],
276
+ "suggested_action": actions,
277
+ }
278
+
279
+ recovery_file = recovery_path / f"recovery-{entry['agent']}.json"
280
+
281
+ # Defense-in-depth: verify recovery_file is contained in recovery_path
282
+ if not verify_path_containment(recovery_file, recovery_path):
283
+ sys.stderr.write(f"Warning: Recovery file path escape detected, skipping: {entry['agent']}\n")
284
+ continue
285
+
286
+ try:
287
+ recovery_file.write_text(json.dumps(advisory, indent=2), encoding='utf-8')
288
+ files_written += 1
289
+ except Exception as e:
290
+ # Fail-open: log error but continue
291
+ sys.stderr.write(f"Warning: Failed to write {recovery_file}: {e}\n")
292
+
293
+ return files_written
294
+
295
+
92
296
  def print_human_table(results):
93
297
  """Print results as a human-readable table."""
94
298
  if not results:
@@ -140,6 +344,21 @@ def main():
140
344
  action="store_true",
141
345
  help="Exit 1 if any agent is stalled (default: always exit 0)",
142
346
  )
347
+ parser.add_argument(
348
+ "--active-from",
349
+ default=None,
350
+ help="Optional. Directory to scan for task/status files. Agent is ACTIVE only if file exists.",
351
+ )
352
+ parser.add_argument(
353
+ "--emit-recovery",
354
+ action="store_true",
355
+ help="Emit recovery advisory JSON blocks to stdout for each STALLED agent",
356
+ )
357
+ parser.add_argument(
358
+ "--recovery-dir",
359
+ default=None,
360
+ help="Optional; only used with --emit-recovery. Write recovery-<agent>.json files to this directory",
361
+ )
143
362
 
144
363
  args = parser.parse_args()
145
364
 
@@ -147,16 +366,28 @@ def main():
147
366
  transcripts_root = args.transcripts_root if args.transcripts_root else get_transcripts_root()
148
367
 
149
368
  # Scan transcripts
150
- results = scan_transcripts(transcripts_root, args.threshold_seconds)
151
-
152
- # Output
153
- if args.json:
154
- print_json_output(results)
155
- else:
156
- if results is None:
157
- print("no transcripts found")
369
+ results = scan_transcripts(transcripts_root, args.threshold_seconds, args.active_from)
370
+
371
+ # Emit recovery advisories if requested
372
+ if args.emit_recovery and results:
373
+ for advisory_json in emit_recovery_advisories(results):
374
+ print(advisory_json)
375
+
376
+ # Write recovery files if requested
377
+ if args.emit_recovery and args.recovery_dir and results:
378
+ write_recovery_files(results, args.recovery_dir)
379
+
380
+ # Output (human-readable or JSON)
381
+ # Note: if --emit-recovery was used, recovery advisories already printed above
382
+ if not args.emit_recovery:
383
+ # Only print human-readable/JSON if we're not emitting recovery advisories
384
+ if args.json:
385
+ print_json_output(results)
158
386
  else:
159
- print_human_table(results)
387
+ if results is None:
388
+ print("no transcripts found")
389
+ else:
390
+ print_human_table(results)
160
391
 
161
392
  # Determine exit code
162
393
  exit_code = 0
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tools.stateapi_lint — Scanner for direct state file opens outside the API.
4
+
5
+ Detects violations of the "reads go through state_store.read_api" rule by scanning
6
+ ui/ and tools/ for direct opens of state files (tracker.json, orchestrator-status.json,
7
+ heartbeat files, OUTCOMES-LEDGER.md) outside the read_api module.
8
+
9
+ Writers (state_store/export.py, state_store/ingest.py, etc.) are allowlisted.
10
+
11
+ RATCHET MODE (hardening):
12
+ Baseline is keyed by file + pattern-id (NOT line numbers).
13
+ Without --update-baseline flag:
14
+ - FAILS if baseline contains entries absent from current scan (stale baseline)
15
+ - FAILS if current violations missing from baseline (new violations)
16
+ - PASS only if: current violations exactly match baseline entries
17
+ With --update-baseline flag:
18
+ - Allows regenerating the baseline (sets current as new baseline)
19
+
20
+ CI MUST NEVER pass --update-baseline (hand-edits of baseline detected & fail).
21
+
22
+ Exit codes:
23
+ 0 = all checks pass
24
+ 1 = violations detected or baseline mismatch
25
+ 2 = error
26
+
27
+ Usage:
28
+ python tools/stateapi_lint.py [--root REPO_ROOT] [--json] [--update-baseline]
29
+
30
+ Options:
31
+ --root REPO_ROOT: Repository root (default: cwd)
32
+ --json: Output JSON instead of text
33
+ --update-baseline: Regenerate baseline from current violations (CI must never use)
34
+ """
35
+ import json
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+
40
+
41
+ # State files that should only be read via the API
42
+ STATE_FILES_TO_PROTECT = [
43
+ "tracker.json",
44
+ "orchestrator-status.json",
45
+ "OUTCOMES-LEDGER.md",
46
+ ".watchdog-heartbeat",
47
+ ".monitor-heartbeat",
48
+ ".orchestrator-heartbeat",
49
+ ]
50
+
51
+ # File patterns that are WRITERS and allowed to access state files directly
52
+ WRITER_ALLOWLIST = [
53
+ "state_store/export.py",
54
+ "state_store/ingest.py",
55
+ "state_store/read_api.py", # The read API facade itself (reads the state files)
56
+ "state_store/write_api.py", # The write API facade (reads/writes the projection atomically)
57
+ "ui/collectors.py", # Some readers also export/flush
58
+ "tools/cost.py", # Parses ledger
59
+ ]
60
+
61
+ # Directories to scan for violations
62
+ SCAN_DIRS = ["ui", "tools", "state_store"] # Include state_store to catch internal issues
63
+
64
+
65
+ def find_direct_opens(repo_root):
66
+ """Scan repo for direct opens of protected state files outside the API.
67
+
68
+ Returns violations keyed by file + pattern-id (not line numbers).
69
+ Pattern-id is the matched pattern index, making the key stable across line edits.
70
+
71
+ Args:
72
+ repo_root: Path to repository root
73
+
74
+ Returns:
75
+ list: List of violation keys (file@pattern-id format)
76
+ """
77
+ repo_root = Path(repo_root)
78
+ violations = []
79
+
80
+ # Pattern to detect file opens: Path(...).read_text(), open(...), json.load(open(...)), etc.
81
+ # Each pattern gets an ID for stable keying across line number changes
82
+ read_patterns = [
83
+ (r'["\']tracker\.json["\']', "tracker-json"),
84
+ (r'["\']orchestrator-status\.json["\']', "status-json"),
85
+ (r'["\']OUTCOMES-LEDGER\.md["\']', "ledger-md"),
86
+ (r'["\']\.watchdog-heartbeat["\']', "watchdog-hb"),
87
+ (r'["\']\.monitor-heartbeat["\']', "monitor-hb"),
88
+ (r'["\']\.orchestrator-heartbeat["\']', "orchestrator-hb"),
89
+ (r'state\s*/\s*tracker\.json', "state-tracker-json"),
90
+ (r'state\s*/\s*orchestrator-status', "state-status-json"),
91
+ (r'state\s*/\s*.*heartbeat', "state-heartbeat"),
92
+ ]
93
+
94
+ for scan_dir in SCAN_DIRS:
95
+ scan_path = repo_root / scan_dir
96
+ if not scan_path.exists():
97
+ continue
98
+
99
+ for py_file in scan_path.rglob("*.py"):
100
+ # Check if this file is in the allowlist
101
+ relative_path = py_file.relative_to(repo_root)
102
+ is_allowed = any(
103
+ str(relative_path).replace("\\", "/") == allow.replace("\\", "/")
104
+ for allow in WRITER_ALLOWLIST
105
+ )
106
+
107
+ if is_allowed:
108
+ continue
109
+
110
+ try:
111
+ content = py_file.read_text(encoding="utf-8", errors="replace")
112
+ except Exception:
113
+ continue
114
+
115
+ # Skip if file imports read_api (it's using the facade correctly)
116
+ if "from state_store.read_api import" in content or "import state_store.read_api" in content:
117
+ continue
118
+
119
+ # Scan for violations
120
+ for line_num, line in enumerate(content.split("\n"), 1):
121
+ for pattern, pattern_id in read_patterns:
122
+ if re.search(pattern, line):
123
+ # Additional filter: skip comment lines and string literals in docstrings
124
+ if line.strip().startswith("#"):
125
+ continue
126
+ if '"""' in line or "'''" in line:
127
+ continue
128
+
129
+ # Key is file@pattern-id (stable across line edits)
130
+ # Use posix separators for cross-platform consistency
131
+ violation_key = f"{relative_path.as_posix()}@{pattern_id}"
132
+ if violation_key not in violations:
133
+ violations.append(violation_key)
134
+ break # Only report once per line per file
135
+
136
+ return sorted(violations)
137
+
138
+
139
+ def save_baseline(baseline_file, data):
140
+ """Save violations baseline to JSON file.
141
+
142
+ Args:
143
+ baseline_file: Path to baseline file
144
+ data: dict with "violations" list
145
+ """
146
+ baseline_file = Path(baseline_file)
147
+ baseline_file.parent.mkdir(parents=True, exist_ok=True)
148
+ baseline_file.write_text(json.dumps(data, indent=2))
149
+
150
+
151
+ def load_baseline(baseline_file):
152
+ """Load violations baseline from JSON file.
153
+
154
+ Returns:
155
+ dict with "violations" list, or empty dict if file missing.
156
+ """
157
+ baseline_file = Path(baseline_file)
158
+ if not baseline_file.exists():
159
+ return {"violations": []}
160
+
161
+ try:
162
+ return json.loads(baseline_file.read_text())
163
+ except Exception:
164
+ return {"violations": []}
165
+
166
+
167
+ def normalize_key(key):
168
+ """Normalize violation keys to forward-slash separators for cross-platform consistency.
169
+
170
+ Converts backslash separators (legacy Windows baseline entries) to forward slashes.
171
+
172
+ Args:
173
+ key: Violation key string
174
+
175
+ Returns:
176
+ str: Key with forward-slash separators
177
+ """
178
+ return key.replace("\\", "/")
179
+
180
+
181
+ def check_ratchet(baseline_violations, current_violations):
182
+ """Check ratchet: baseline and current must match exactly.
183
+
184
+ Fails if:
185
+ 1. Baseline contains entries absent from current (stale baseline)
186
+ 2. Current violations missing from baseline (new violations)
187
+
188
+ Only passes if current violations exactly match baseline (bidirectional check).
189
+ Normalizes both sets to forward-slash separators for cross-platform consistency.
190
+
191
+ Args:
192
+ baseline_violations: list of violation keys from baseline
193
+ current_violations: list of violation keys from current scan
194
+
195
+ Returns:
196
+ tuple: (is_ok, stale_entries, new_violations)
197
+ is_ok (bool): True only if baseline == current
198
+ stale_entries (list): Entries in baseline not in current
199
+ new_violations (list): Entries in current not in baseline
200
+ """
201
+ # Normalize both to forward slashes for comparison
202
+ baseline_set = set(normalize_key(v) for v in baseline_violations)
203
+ current_set = set(normalize_key(v) for v in current_violations)
204
+
205
+ stale = list(baseline_set - current_set)
206
+ new = list(current_set - baseline_set)
207
+
208
+ is_ok = (len(stale) == 0 and len(new) == 0)
209
+ return is_ok, sorted(stale), sorted(new)
210
+
211
+
212
+ def main(argv=None):
213
+ """CLI entry point."""
214
+ argv = sys.argv[1:] if argv is None else argv
215
+
216
+ repo_root = None
217
+ output_format = "text"
218
+ baseline_file = None
219
+ update_baseline = False
220
+
221
+ # Parse arguments
222
+ i = 0
223
+ while i < len(argv):
224
+ arg = argv[i]
225
+ if arg == "--root":
226
+ i += 1
227
+ if i < len(argv):
228
+ repo_root = argv[i]
229
+ i += 1
230
+ elif arg.startswith("--root="):
231
+ repo_root = arg[len("--root="):]
232
+ i += 1
233
+ elif arg == "--json":
234
+ output_format = "json"
235
+ i += 1
236
+ elif arg == "--baseline":
237
+ i += 1
238
+ if i < len(argv):
239
+ baseline_file = argv[i]
240
+ i += 1
241
+ elif arg == "--update-baseline":
242
+ update_baseline = True
243
+ i += 1
244
+ else:
245
+ print(f"Unknown argument: {arg}", file=sys.stderr)
246
+ return 2
247
+
248
+ if repo_root is None:
249
+ repo_root = Path.cwd()
250
+ else:
251
+ repo_root = Path(repo_root)
252
+
253
+ if baseline_file is None:
254
+ baseline_file = repo_root / ".stateapi-baseline.json"
255
+
256
+ # Scan for violations
257
+ violations = find_direct_opens(str(repo_root))
258
+
259
+ # Load baseline
260
+ baseline_data = load_baseline(str(baseline_file))
261
+ baseline_violations = baseline_data.get("violations", [])
262
+
263
+ # If --update-baseline, save current as baseline and exit
264
+ if update_baseline:
265
+ save_baseline(baseline_file, {"violations": violations})
266
+ if output_format == "json":
267
+ print(json.dumps({"ok": True, "message": "Baseline updated", "count": len(violations)}, indent=2))
268
+ else:
269
+ print(f"Baseline updated: {len(violations)} violation(s) recorded")
270
+ return 0
271
+
272
+ # Check ratchet: baseline must match current exactly
273
+ is_ok, stale_entries, new_violations = check_ratchet(baseline_violations, violations)
274
+
275
+ if output_format == "json":
276
+ result = {
277
+ "ok": is_ok,
278
+ "violations": violations,
279
+ "baseline_count": len(baseline_violations),
280
+ "current_count": len(violations),
281
+ "stale_entries": stale_entries,
282
+ "new_violations": new_violations,
283
+ }
284
+ print(json.dumps(result, indent=2))
285
+ else:
286
+ # Text format
287
+ print(f"State API lint: {len(violations)} violation(s) found")
288
+ if baseline_violations:
289
+ print(f" (baseline: {len(baseline_violations)})")
290
+
291
+ if violations:
292
+ for v in sorted(violations):
293
+ print(f" {v}")
294
+
295
+ # Report stale entries (in baseline but not current)
296
+ if stale_entries:
297
+ print(f"\nSTALE baseline entries ({len(stale_entries)}) — hand-edited or fixed?:")
298
+ for v in stale_entries:
299
+ print(f" {v}")
300
+
301
+ # Report new violations (in current but not baseline)
302
+ if new_violations:
303
+ print(f"\nNEW violations ({len(new_violations)}):")
304
+ for v in new_violations:
305
+ print(f" {v}")
306
+
307
+ # Final verdict
308
+ if is_ok:
309
+ if violations:
310
+ print(f"\nPASS: All {len(violations)} violations match baseline")
311
+ else:
312
+ print("\nPASS: No violations")
313
+ return 0
314
+ else:
315
+ if stale_entries and new_violations:
316
+ print(f"\nFAIL: {len(stale_entries)} stale + {len(new_violations)} new violation(s)")
317
+ elif stale_entries:
318
+ print(f"\nFAIL: {len(stale_entries)} stale baseline entries (hand-edited?)")
319
+ else:
320
+ print(f"\nFAIL: {len(new_violations)} new violation(s) detected")
321
+ return 1
322
+
323
+
324
+ if __name__ == "__main__":
325
+ sys.exit(main())