@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.
- package/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/tools/stall_check.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
#
|
|
153
|
-
if args.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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())
|