@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/fleet_ledger.py
CHANGED
|
@@ -7,6 +7,12 @@ Subcommands:
|
|
|
7
7
|
Manually append one ledger line (verdict = OK|FAILED|EMPTY|HUNG, default OK)
|
|
8
8
|
phase = build|verify|repair|other (optional, default null)
|
|
9
9
|
wave = wave number as integer (optional, default null)
|
|
10
|
+
append-wave --report-file <path> --wave <id> --phase <phase> --timestamp <iso>
|
|
11
|
+
Append one ledger row from a workflow report JSON for a specific phase.
|
|
12
|
+
Report shape: {tokens:{buildOut,verifyOut,repairOut,...}, integration:{green:bool,...}, ...}
|
|
13
|
+
Creates: model=haiku, verdict from integration.green, tokens_out from tokens.<phase>Out.
|
|
14
|
+
Skips if identical wave+phase+timestamp row already exists (idempotent).
|
|
15
|
+
Tolerates missing fields (defaults to 0).
|
|
10
16
|
harvest
|
|
11
17
|
Scan session tasks directories for agent outcomes and append missing entries.
|
|
12
18
|
(Tracks state in ledger directory for resume capability)
|
|
@@ -56,6 +62,62 @@ def ensure_ledger_header():
|
|
|
56
62
|
ledger_file.write_text(header, encoding='utf-8')
|
|
57
63
|
|
|
58
64
|
|
|
65
|
+
def _validate_iso_timestamp(ts):
|
|
66
|
+
"""Validate and normalize ISO 8601 timestamp.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
ts: Timestamp string to validate
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Validated timestamp string or None if invalid
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If timestamp is invalid
|
|
76
|
+
"""
|
|
77
|
+
import re
|
|
78
|
+
if not ts:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
ts_str = str(ts).strip()
|
|
82
|
+
|
|
83
|
+
# Reject if contains control characters, pipes, newlines, etc.
|
|
84
|
+
if '\n' in ts_str or '\r' in ts_str or '|' in ts_str:
|
|
85
|
+
raise ValueError(f"Timestamp contains forbidden characters: {repr(ts_str)}")
|
|
86
|
+
|
|
87
|
+
# Validate ISO 8601 format: YYYY-MM-DDTHH:MM:SS[.fff][+HH:MM]
|
|
88
|
+
# This is a strict pattern to prevent injection via malformed timestamps
|
|
89
|
+
iso_pattern = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:[+-]\d{2}:\d{2}|Z)?$'
|
|
90
|
+
if not re.match(iso_pattern, ts_str):
|
|
91
|
+
raise ValueError(f"Timestamp does not match ISO 8601 format: {ts_str}")
|
|
92
|
+
|
|
93
|
+
return ts_str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_phase(phase):
|
|
97
|
+
"""Validate and normalize phase name.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
phase: Phase name to validate
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Validated phase string (max 15 chars) or empty string if None
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If phase contains forbidden characters
|
|
107
|
+
"""
|
|
108
|
+
if phase is None:
|
|
109
|
+
return ''
|
|
110
|
+
|
|
111
|
+
phase_str = str(phase).strip()
|
|
112
|
+
|
|
113
|
+
# Reject if contains forbidden characters (pipes, newlines, etc.)
|
|
114
|
+
if '|' in phase_str or '\n' in phase_str or '\r' in phase_str:
|
|
115
|
+
raise ValueError(f"Phase contains forbidden characters: {repr(phase_str)}")
|
|
116
|
+
|
|
117
|
+
# Truncate to 15 chars for idempotency consistency
|
|
118
|
+
return phase_str[:15]
|
|
119
|
+
|
|
120
|
+
|
|
59
121
|
def append_ledger_line(iso_ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict='OK', phase=None, wave=None):
|
|
60
122
|
"""Append one line to the ledger.
|
|
61
123
|
|
|
@@ -69,20 +131,32 @@ def append_ledger_line(iso_ts, agent_type, model, duration_sec, tokens_in, token
|
|
|
69
131
|
verdict: OK|FAILED|EMPTY|HUNG (default OK)
|
|
70
132
|
phase: build|verify|repair|other (optional, default None)
|
|
71
133
|
wave: wave number as int or None (optional, default None)
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: If timestamp or phase validation fails
|
|
72
137
|
"""
|
|
73
138
|
ensure_ledger_header()
|
|
74
139
|
ledger_file, _, _ = get_ledger_paths()
|
|
75
140
|
|
|
141
|
+
# Validate and sanitize iso_ts: must be valid ISO 8601 format
|
|
142
|
+
try:
|
|
143
|
+
iso_ts = _validate_iso_timestamp(iso_ts)
|
|
144
|
+
if iso_ts is None:
|
|
145
|
+
iso_ts = '-'
|
|
146
|
+
except ValueError as e:
|
|
147
|
+
# Reject invalid timestamps to prevent injection
|
|
148
|
+
raise ValueError(f"Invalid timestamp: {e}") from e
|
|
149
|
+
|
|
76
150
|
# Sanitize fields: no pipes, truncate to reasonable length
|
|
77
151
|
agent_type = str(agent_type or '-').replace('|', '').strip()[:30]
|
|
78
152
|
model = str(model or '-').replace('|', '').strip()[:30]
|
|
79
153
|
verdict = str(verdict or 'OK').replace('|', '').strip()[:10]
|
|
80
154
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
phase =
|
|
84
|
-
|
|
85
|
-
phase
|
|
155
|
+
# Validate and sanitize optional fields
|
|
156
|
+
try:
|
|
157
|
+
phase = _validate_phase(phase)
|
|
158
|
+
except ValueError as e:
|
|
159
|
+
raise ValueError(f"Invalid phase: {e}") from e
|
|
86
160
|
|
|
87
161
|
if wave is not None:
|
|
88
162
|
try:
|
|
@@ -141,6 +215,7 @@ def harvest():
|
|
|
141
215
|
seen_agents, _ = load_harvest_state()
|
|
142
216
|
new_seen = set(seen_agents)
|
|
143
217
|
harvested_count = 0
|
|
218
|
+
skipped_count = 0
|
|
144
219
|
|
|
145
220
|
# Determine temp root for tasks scanning
|
|
146
221
|
if os.environ.get("AESOP_TEMP_ROOT"):
|
|
@@ -170,6 +245,11 @@ def harvest():
|
|
|
170
245
|
except json.JSONDecodeError:
|
|
171
246
|
continue
|
|
172
247
|
|
|
248
|
+
# Type guard: skip non-dict scalars (int, string, etc.)
|
|
249
|
+
if not isinstance(obj, dict):
|
|
250
|
+
skipped_count += 1
|
|
251
|
+
continue
|
|
252
|
+
|
|
173
253
|
# Look for agent spawn events (type='assistant' with Agent/Task tool_use)
|
|
174
254
|
# or completion events (type='assistant' with usage data)
|
|
175
255
|
agent_id = obj.get('agentId') or obj.get('uuid')
|
|
@@ -219,6 +299,8 @@ def harvest():
|
|
|
219
299
|
|
|
220
300
|
ledger_file, _, _ = get_ledger_paths()
|
|
221
301
|
print(f'Harvested {harvested_count} new agent outcomes to {ledger_file}')
|
|
302
|
+
if skipped_count > 0:
|
|
303
|
+
print(f'Skipped {skipped_count} malformed JSONL lines (non-dict scalars)')
|
|
222
304
|
return harvested_count
|
|
223
305
|
|
|
224
306
|
|
|
@@ -260,6 +342,101 @@ def rotate():
|
|
|
260
342
|
print(f'Live ledger now has {len(new_ledger) - len(header_lines)} data lines')
|
|
261
343
|
|
|
262
344
|
|
|
345
|
+
def append_wave(report_file, wave, phase, timestamp):
|
|
346
|
+
"""Append one row to the ledger from a workflow report JSON.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
report_file: Path to JSON report file
|
|
350
|
+
wave: Wave number (string or int)
|
|
351
|
+
phase: Phase name (build|verify|repair)
|
|
352
|
+
timestamp: ISO 8601 timestamp (must be provided by caller)
|
|
353
|
+
|
|
354
|
+
Workflow report shape:
|
|
355
|
+
{
|
|
356
|
+
"tokens": {
|
|
357
|
+
"buildOut": int,
|
|
358
|
+
"verifyOut": int,
|
|
359
|
+
"repairOut": int,
|
|
360
|
+
"totalOut": int,
|
|
361
|
+
...
|
|
362
|
+
},
|
|
363
|
+
"integration": {
|
|
364
|
+
"green": bool,
|
|
365
|
+
...
|
|
366
|
+
},
|
|
367
|
+
...
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Tuple: (success, message)
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
report_path = Path(report_file)
|
|
375
|
+
if not report_path.exists():
|
|
376
|
+
return False, f"Report file not found: {report_file}"
|
|
377
|
+
|
|
378
|
+
report_text = report_path.read_text(encoding='utf-8')
|
|
379
|
+
report = json.loads(report_text)
|
|
380
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
381
|
+
return False, f"Failed to read/parse report: {e}"
|
|
382
|
+
|
|
383
|
+
# Extract data from report with graceful defaults
|
|
384
|
+
tokens_section = report.get('tokens', {})
|
|
385
|
+
integration_section = report.get('integration', {})
|
|
386
|
+
|
|
387
|
+
# Determine verdict from integration.green
|
|
388
|
+
is_green = integration_section.get('green', False)
|
|
389
|
+
verdict = 'OK' if is_green else 'FAILED'
|
|
390
|
+
|
|
391
|
+
# Extract tokens_out based on phase
|
|
392
|
+
phase_key = f'{phase}Out'
|
|
393
|
+
tokens_out = tokens_section.get(phase_key, 0)
|
|
394
|
+
try:
|
|
395
|
+
tokens_out = int(tokens_out) if tokens_out else 0
|
|
396
|
+
except (ValueError, TypeError):
|
|
397
|
+
tokens_out = 0
|
|
398
|
+
|
|
399
|
+
# Model is always haiku for append-wave
|
|
400
|
+
model = 'haiku'
|
|
401
|
+
agent_type = 'waverun'
|
|
402
|
+
|
|
403
|
+
# Validate inputs
|
|
404
|
+
try:
|
|
405
|
+
wave_num = int(wave) if wave else None
|
|
406
|
+
except (ValueError, TypeError):
|
|
407
|
+
return False, f"Invalid wave number: {wave}"
|
|
408
|
+
|
|
409
|
+
# Validate and sanitize inputs consistently with append_ledger_line
|
|
410
|
+
# This ensures idempotency check uses the same values as the actual write
|
|
411
|
+
try:
|
|
412
|
+
iso_ts_sanitized = _validate_iso_timestamp(timestamp)
|
|
413
|
+
if iso_ts_sanitized is None:
|
|
414
|
+
iso_ts_sanitized = '-'
|
|
415
|
+
except ValueError as e:
|
|
416
|
+
return False, f"Invalid timestamp: {e}"
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
phase_sanitized = _validate_phase(phase)
|
|
420
|
+
except ValueError as e:
|
|
421
|
+
return False, f"Invalid phase: {e}"
|
|
422
|
+
|
|
423
|
+
# Idempotency check: see if this exact row already exists
|
|
424
|
+
existing_rows = parse_ledger_rows()
|
|
425
|
+
for row in existing_rows:
|
|
426
|
+
if (row['iso_ts'] == iso_ts_sanitized and
|
|
427
|
+
row['phase'] == phase_sanitized and
|
|
428
|
+
row['wave'] == wave_num and
|
|
429
|
+
row['model'] == model):
|
|
430
|
+
return True, f"Row already exists (wave={wave_num}, phase={phase_sanitized}, ts={iso_ts_sanitized}); skipping"
|
|
431
|
+
|
|
432
|
+
# Append the row with validated inputs
|
|
433
|
+
try:
|
|
434
|
+
append_ledger_line(timestamp, agent_type, model, 0, 0, tokens_out, verdict, phase, wave_num)
|
|
435
|
+
return True, f"Appended: wave={wave_num} phase={phase_sanitized} tokens_out={tokens_out} verdict={verdict}"
|
|
436
|
+
except ValueError as e:
|
|
437
|
+
return False, f"Failed to append row: {e}"
|
|
438
|
+
|
|
439
|
+
|
|
263
440
|
def parse_ledger_rows():
|
|
264
441
|
"""Parse and return all ledger rows as structured data.
|
|
265
442
|
|
|
@@ -421,8 +598,33 @@ def main():
|
|
|
421
598
|
phase = sys.argv[9] if len(sys.argv) > 9 else None
|
|
422
599
|
wave = sys.argv[10] if len(sys.argv) > 10 else None
|
|
423
600
|
|
|
424
|
-
|
|
425
|
-
|
|
601
|
+
try:
|
|
602
|
+
append_ledger_line(ts, agent_type, model, dur, ti, to, verdict, phase, wave)
|
|
603
|
+
print(f'Appended: {ts} {agent_type} {model} {dur}s {ti}->{to} [{verdict}] phase={phase} wave={wave}')
|
|
604
|
+
except ValueError as e:
|
|
605
|
+
print(f'Error: {e}', file=sys.stderr)
|
|
606
|
+
sys.exit(1)
|
|
607
|
+
|
|
608
|
+
elif cmd == 'append-wave':
|
|
609
|
+
# append-wave --report-file <path> --wave <id> --phase <phase> --timestamp <iso>
|
|
610
|
+
import argparse
|
|
611
|
+
parser = argparse.ArgumentParser(description='Append wave outcome to ledger from report JSON')
|
|
612
|
+
parser.add_argument('--report-file', required=True, help='Path to workflow report JSON')
|
|
613
|
+
parser.add_argument('--wave', required=True, help='Wave number')
|
|
614
|
+
parser.add_argument('--phase', required=True, help='Phase name (build|verify|repair)')
|
|
615
|
+
parser.add_argument('--timestamp', required=True, help='ISO 8601 timestamp')
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
args = parser.parse_args(sys.argv[2:])
|
|
619
|
+
except SystemExit:
|
|
620
|
+
sys.exit(1)
|
|
621
|
+
|
|
622
|
+
success, message = append_wave(args.report_file, args.wave, args.phase, args.timestamp)
|
|
623
|
+
if success:
|
|
624
|
+
print(message)
|
|
625
|
+
else:
|
|
626
|
+
print(message, file=sys.stderr)
|
|
627
|
+
sys.exit(0 if success else 1)
|
|
426
628
|
|
|
427
629
|
elif cmd == 'harvest':
|
|
428
630
|
harvest()
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
git_identity_check.py — Validate a repo's effective git user.name/user.email.
|
|
4
|
+
|
|
5
|
+
Validates that a repository's git user identity matches expected values. Can read
|
|
6
|
+
expected values from CLI args (--expect-name/--expect-email) or from an 'identity'
|
|
7
|
+
block in aesop.config.json. Verifies values are physically persisted in .git/config
|
|
8
|
+
(via grep, not just cache).
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python tools/git_identity_check.py --repo <path> --expect-name <name> --expect-email <email> [--mode warn|fail]
|
|
12
|
+
python tools/git_identity_check.py --repo <path> --config aesop.config.json [--mode warn|fail]
|
|
13
|
+
|
|
14
|
+
Modes:
|
|
15
|
+
--mode warn — Print drift report and exit 0 (default)
|
|
16
|
+
--mode fail — Exit 1 if drift detected, exit 0 if matched
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
python tools/git_identity_check.py --repo /path/to/repo --expect-name "Matt Culliton" --expect-email "matt82198@gmail.com" --mode fail
|
|
20
|
+
python tools/git_identity_check.py --repo /path/to/repo --config aesop.config.json --mode fail
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional, Tuple, List
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_identity_from_args(args: List[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
31
|
+
"""
|
|
32
|
+
Parse CLI arguments for --expect-name and --expect-email.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
args: Command line arguments list
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Tuple of (name, email) where either can be None if not provided
|
|
39
|
+
"""
|
|
40
|
+
name = None
|
|
41
|
+
email = None
|
|
42
|
+
|
|
43
|
+
i = 0
|
|
44
|
+
while i < len(args):
|
|
45
|
+
if args[i] == "--expect-name" and i + 1 < len(args):
|
|
46
|
+
name = args[i + 1]
|
|
47
|
+
i += 2
|
|
48
|
+
elif args[i] == "--expect-email" and i + 1 < len(args):
|
|
49
|
+
email = args[i + 1]
|
|
50
|
+
i += 2
|
|
51
|
+
else:
|
|
52
|
+
i += 1
|
|
53
|
+
|
|
54
|
+
return name, email
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_identity_from_config_file(config_path: str) -> Tuple[Optional[str], Optional[str]]:
|
|
58
|
+
"""
|
|
59
|
+
Read identity expectations from aesop.config.json 'identity' block.
|
|
60
|
+
|
|
61
|
+
Config file format:
|
|
62
|
+
{
|
|
63
|
+
"identity": {
|
|
64
|
+
"user_name": "Expected Name",
|
|
65
|
+
"user_email": "expected@example.com"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
config_path: Path to aesop.config.json
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (name, email) where either can be None if not found
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
config_file = Path(config_path)
|
|
77
|
+
if not config_file.exists():
|
|
78
|
+
return None, None
|
|
79
|
+
|
|
80
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
81
|
+
config = json.load(f)
|
|
82
|
+
|
|
83
|
+
identity = config.get("identity", {})
|
|
84
|
+
name = identity.get("user_name")
|
|
85
|
+
email = identity.get("user_email")
|
|
86
|
+
|
|
87
|
+
return name, email
|
|
88
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
89
|
+
return None, None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_git_identity(repo_path: str) -> Tuple[Optional[str], Optional[str]]:
|
|
93
|
+
"""
|
|
94
|
+
Get current git user.name and user.email via git config command (local scope only).
|
|
95
|
+
|
|
96
|
+
Uses: git -C <repo> config --local user.name/user.email
|
|
97
|
+
|
|
98
|
+
Only reads from local repo config, not system/global fallback.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
repo_path: Path to git repository
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Tuple of (name, email) where either can be None if not set
|
|
105
|
+
"""
|
|
106
|
+
name = None
|
|
107
|
+
email = None
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
["git", "-C", repo_path, "config", "--local", "user.name"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
check=False,
|
|
115
|
+
)
|
|
116
|
+
if result.returncode == 0:
|
|
117
|
+
name = result.stdout.strip()
|
|
118
|
+
if not name:
|
|
119
|
+
name = None
|
|
120
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
result = subprocess.run(
|
|
125
|
+
["git", "-C", repo_path, "config", "--local", "user.email"],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
check=False,
|
|
129
|
+
)
|
|
130
|
+
if result.returncode == 0:
|
|
131
|
+
email = result.stdout.strip()
|
|
132
|
+
if not email:
|
|
133
|
+
email = None
|
|
134
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
return name, email
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_physical_git_identity(repo_path: str) -> Tuple[Optional[str], Optional[str]]:
|
|
141
|
+
"""
|
|
142
|
+
Read git user identity directly from .git/config file (grep-based, not cache).
|
|
143
|
+
|
|
144
|
+
This function bypasses git's config cache to detect if the .git/config file
|
|
145
|
+
has physically persisted values that differ from what git command reports.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
repo_path: Path to git repository
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Tuple of (name, email) where either can be None if not found in file
|
|
152
|
+
"""
|
|
153
|
+
name = None
|
|
154
|
+
email = None
|
|
155
|
+
|
|
156
|
+
git_config = Path(repo_path) / ".git" / "config"
|
|
157
|
+
|
|
158
|
+
if not git_config.exists():
|
|
159
|
+
return None, None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
content = git_config.read_text(encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
# Parse user.name
|
|
165
|
+
for line in content.split("\n"):
|
|
166
|
+
line = line.strip()
|
|
167
|
+
if line.startswith("name = "):
|
|
168
|
+
name = line[7:] # Remove "name = " prefix
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
# Parse user.email
|
|
172
|
+
for line in content.split("\n"):
|
|
173
|
+
line = line.strip()
|
|
174
|
+
if line.startswith("email = "):
|
|
175
|
+
email = line[8:] # Remove "email = " prefix
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
return name, email
|
|
179
|
+
except (IOError, OSError):
|
|
180
|
+
return None, None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_identity(
|
|
184
|
+
repo_path: str,
|
|
185
|
+
expected_name: Optional[str] = None,
|
|
186
|
+
expected_email: Optional[str] = None,
|
|
187
|
+
) -> List[str]:
|
|
188
|
+
"""
|
|
189
|
+
Validate repo's git identity against expected values.
|
|
190
|
+
|
|
191
|
+
Checks both git config cache and physical .git/config file. Returns list of
|
|
192
|
+
error messages if validation fails (empty list if all checks pass).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
repo_path: Path to git repository
|
|
196
|
+
expected_name: Expected git user.name (None to skip validation)
|
|
197
|
+
expected_email: Expected git user.email (None to skip validation)
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List of error messages (empty if validation passes)
|
|
201
|
+
"""
|
|
202
|
+
errors = []
|
|
203
|
+
|
|
204
|
+
# Get current identity from git config
|
|
205
|
+
current_name, current_email = get_git_identity(repo_path)
|
|
206
|
+
|
|
207
|
+
# Get physical identity from .git/config file
|
|
208
|
+
physical_name, physical_email = get_physical_git_identity(repo_path)
|
|
209
|
+
|
|
210
|
+
# Check for config cache vs physical file drift
|
|
211
|
+
if current_name != physical_name:
|
|
212
|
+
errors.append(
|
|
213
|
+
f"Git config drift for user.name: cache='{current_name}' vs physical='{physical_name}'"
|
|
214
|
+
)
|
|
215
|
+
if current_email != physical_email:
|
|
216
|
+
errors.append(
|
|
217
|
+
f"Git config drift for user.email: cache='{current_email}' vs physical='{physical_email}'"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Validate against expected values (using physical values as source of truth)
|
|
221
|
+
if expected_name is not None:
|
|
222
|
+
if physical_name != expected_name:
|
|
223
|
+
errors.append(
|
|
224
|
+
f"user.name mismatch: expected '{expected_name}' but git has '{physical_name}'"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if expected_email is not None:
|
|
228
|
+
if physical_email != expected_email:
|
|
229
|
+
errors.append(
|
|
230
|
+
f"user.email mismatch: expected '{expected_email}' but git has '{physical_email}'"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return errors
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
237
|
+
"""
|
|
238
|
+
Main entry point for git identity check.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
argv: Command line arguments (uses sys.argv[1:] if None)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Exit code: 0 on success or --warn, 1 on validation failure in --fail mode
|
|
245
|
+
"""
|
|
246
|
+
if argv is None:
|
|
247
|
+
argv = sys.argv[1:]
|
|
248
|
+
|
|
249
|
+
# Parse arguments
|
|
250
|
+
repo_path = None
|
|
251
|
+
config_path = None
|
|
252
|
+
mode = "warn" # default mode
|
|
253
|
+
expect_name = None
|
|
254
|
+
expect_email = None
|
|
255
|
+
|
|
256
|
+
i = 0
|
|
257
|
+
while i < len(argv):
|
|
258
|
+
if argv[i] == "--repo" and i + 1 < len(argv):
|
|
259
|
+
repo_path = argv[i + 1]
|
|
260
|
+
i += 2
|
|
261
|
+
elif argv[i] == "--config" and i + 1 < len(argv):
|
|
262
|
+
config_path = argv[i + 1]
|
|
263
|
+
i += 2
|
|
264
|
+
elif argv[i] == "--mode" and i + 1 < len(argv):
|
|
265
|
+
mode = argv[i + 1]
|
|
266
|
+
i += 2
|
|
267
|
+
elif argv[i] == "--expect-name" and i + 1 < len(argv):
|
|
268
|
+
expect_name = argv[i + 1]
|
|
269
|
+
i += 2
|
|
270
|
+
elif argv[i] == "--expect-email" and i + 1 < len(argv):
|
|
271
|
+
expect_email = argv[i + 1]
|
|
272
|
+
i += 2
|
|
273
|
+
else:
|
|
274
|
+
i += 1
|
|
275
|
+
|
|
276
|
+
# Validate required arguments
|
|
277
|
+
if not repo_path:
|
|
278
|
+
print("Error: --repo is required", file=sys.stderr)
|
|
279
|
+
return 2
|
|
280
|
+
|
|
281
|
+
# Read expectations: CLI args take precedence over config file
|
|
282
|
+
cli_name, cli_email = get_identity_from_args(argv)
|
|
283
|
+
if cli_name is not None or cli_email is not None:
|
|
284
|
+
expect_name = cli_name if cli_name is not None else expect_name
|
|
285
|
+
expect_email = cli_email if cli_email is not None else expect_email
|
|
286
|
+
elif config_path:
|
|
287
|
+
config_name, config_email = get_identity_from_config_file(config_path)
|
|
288
|
+
expect_name = config_name if config_name is not None else expect_name
|
|
289
|
+
expect_email = config_email if config_email is not None else expect_email
|
|
290
|
+
|
|
291
|
+
# If still no expectations, nothing to validate
|
|
292
|
+
if expect_name is None and expect_email is None:
|
|
293
|
+
print("Error: Must provide --expect-name/--expect-email or --config", file=sys.stderr)
|
|
294
|
+
return 2
|
|
295
|
+
|
|
296
|
+
# Validate
|
|
297
|
+
errors = validate_identity(repo_path, expect_name, expect_email)
|
|
298
|
+
|
|
299
|
+
# Report results
|
|
300
|
+
if errors:
|
|
301
|
+
print(f"Git identity validation failed for {repo_path}:", file=sys.stderr)
|
|
302
|
+
for error in errors:
|
|
303
|
+
print(f" - {error}", file=sys.stderr)
|
|
304
|
+
|
|
305
|
+
if mode == "fail":
|
|
306
|
+
return 1
|
|
307
|
+
else: # warn mode
|
|
308
|
+
return 0
|
|
309
|
+
else:
|
|
310
|
+
print(f"Git identity validation passed for {repo_path}")
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
sys.exit(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aesop health-score — readiness score for primed projects
|
|
5
|
+
*
|
|
6
|
+
* Calculates a weighted 0-100 score based on:
|
|
7
|
+
* - Config validity (15 points)
|
|
8
|
+
* - Git pre-push hook installed (15 points)
|
|
9
|
+
* - CLAUDE.md present (15 points)
|
|
10
|
+
* - State directory writable (10 points)
|
|
11
|
+
* - Daemon heartbeats fresh (15 points)
|
|
12
|
+
* - Git identity configured (15 points)
|
|
13
|
+
* - Secret-scan runnable (15 points)
|
|
14
|
+
*
|
|
15
|
+
* Exit code 0 = success (always produces a score).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const pythonScript = path.join(__dirname, 'health_score.py');
|
|
21
|
+
|
|
22
|
+
// Get arguments: process.argv includes [node, script, health-score, --cwd, .]
|
|
23
|
+
let args = process.argv.slice(2);
|
|
24
|
+
|
|
25
|
+
// If first arg is 'health-score' (command name), remove it
|
|
26
|
+
if (args[0] === 'health-score') {
|
|
27
|
+
args = args.slice(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = spawnSync('python3', [pythonScript, ...args], {
|
|
31
|
+
stdio: 'inherit',
|
|
32
|
+
timeout: 30000
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (result.error) {
|
|
36
|
+
console.error(`Error running health-score: ${result.error.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
process.exitCode = result.status || 0;
|