@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
@@ -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
- # Sanitize optional fields
82
- if phase is not None:
83
- phase = str(phase).replace('|', '').strip()[:15]
84
- else:
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
- append_ledger_line(ts, agent_type, model, dur, ti, to, verdict, phase, wave)
425
- print(f'Appended: {ts} {agent_type} {model} {dur}s {ti}->{to} [{verdict}] phase={phase} wave={wave}')
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;