@rubytech/create-maxy-code 0.1.58 → 0.1.60

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 (182) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +30 -19
  3. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +35 -90
  4. package/payload/platform/plugins/docs/references/admin-session.md +17 -16
  5. package/payload/platform/plugins/docs/references/platform.md +3 -3
  6. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  7. package/payload/platform/plugins/loop/PLUGIN.md +13 -1
  8. package/payload/platform/plugins/loop/mcp/dist/index.js +8 -7
  9. package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
  10. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
  11. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  12. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
  13. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
  14. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
  15. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
  16. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js +165 -0
  17. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
  18. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
  19. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  20. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
  21. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  22. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  23. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
  24. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
  25. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
  26. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js +11 -28
  27. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
  28. package/payload/platform/plugins/loop/mcp/src/index.ts +10 -7
  29. package/payload/platform/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
  30. package/payload/platform/plugins/loop/mcp/src/lib/key-store.ts +221 -0
  31. package/payload/platform/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
  32. package/payload/platform/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
  33. package/payload/platform/plugins/loop/mcp/src/tools/key-register.ts +14 -39
  34. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +8 -0
  35. package/payload/platform/plugins/preval/PLUGIN.md +39 -0
  36. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -0
  37. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +265 -0
  38. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
  39. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +253 -0
  40. package/payload/platform/plugins/property-data/.claude-plugin/plugin.json +1 -1
  41. package/payload/platform/plugins/property-data/PLUGIN.md +60 -2
  42. package/payload/platform/plugins/property-data/mcp/dist/index.js +95 -12
  43. package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -1
  44. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
  45. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  46. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
  47. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
  48. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
  49. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
  50. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
  51. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
  52. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
  53. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
  54. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
  55. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
  56. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
  57. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  58. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +15 -2
  59. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  60. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  61. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
  62. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
  63. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
  64. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
  65. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
  66. package/payload/platform/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
  67. package/payload/platform/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
  68. package/payload/platform/plugins/property-data/mcp/src/index.ts +171 -11
  69. package/payload/platform/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
  70. package/payload/platform/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
  71. package/payload/platform/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
  72. package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +22 -2
  73. package/payload/platform/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
  74. package/payload/platform/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
  75. package/payload/platform/plugins/property-data/skills/property-data/SKILL.md +19 -0
  76. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/http-server.js +25 -260
  78. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  79. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +3 -7
  80. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  81. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +6 -32
  82. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  83. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +6 -24
  84. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  85. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +18 -91
  86. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  87. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/reaper.js +0 -11
  89. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +0 -21
  91. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  93. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  94. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  95. package/payload/premium-plugins/real-agent/BUNDLE.md +5 -3
  96. package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
  97. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  98. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  99. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +13 -1
  100. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +8 -7
  101. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
  102. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
  103. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  104. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
  105. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
  106. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
  107. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
  108. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js +165 -0
  109. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
  111. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  112. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
  113. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  114. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  115. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
  116. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
  117. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
  118. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js +11 -28
  119. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
  120. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +10 -7
  121. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
  122. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/key-store.ts +221 -0
  123. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
  124. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
  125. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-register.ts +14 -39
  126. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +8 -0
  127. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +39 -0
  128. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -0
  129. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +265 -0
  130. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +143 -0
  131. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +253 -0
  132. package/payload/premium-plugins/real-agent/plugins/property-data/.claude-plugin/plugin.json +1 -1
  133. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +60 -2
  134. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +95 -12
  135. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -1
  136. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
  137. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  138. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
  139. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
  140. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
  141. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
  142. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
  143. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
  144. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
  145. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
  146. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
  147. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
  148. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts +10 -0
  149. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  150. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +15 -2
  151. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  152. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  153. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
  154. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
  155. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
  156. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
  157. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
  158. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/endpoints.test.ts +252 -0
  159. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +16 -2
  160. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +171 -11
  161. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
  162. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
  163. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
  164. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +22 -2
  165. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
  166. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
  167. package/payload/premium-plugins/real-agent/plugins/property-data/skills/property-data/SKILL.md +19 -0
  168. package/payload/server/public/assets/{admin-2ZJmzJ_g.js → admin-Bp-BjBCX.js} +51 -51
  169. package/payload/server/public/assets/{data-DlmoKxmN.js → data-BGUAGVkV.js} +1 -1
  170. package/payload/server/public/assets/{graph-BVvcFSEQ.js → graph-MvYxZOBF.js} +1 -1
  171. package/payload/server/public/assets/{graph-labels-cCvgu38Y.js → graph-labels-D865qb3K.js} +1 -1
  172. package/payload/server/public/assets/{page-BnAxPkmg.js → page--hOVRrgN.js} +1 -1
  173. package/payload/server/public/assets/{page-BnaN3u86.js → page-C2b1nlOc.js} +1 -1
  174. package/payload/server/public/data.html +3 -3
  175. package/payload/server/public/graph.html +3 -3
  176. package/payload/server/public/index.html +4 -4
  177. package/payload/server/server.js +18 -83
  178. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +0 -51
  179. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +0 -1
  180. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +0 -196
  181. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +0 -1
  182. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +0 -7
@@ -0,0 +1,265 @@
1
+ """property-preval renderer.
2
+
3
+ Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
4
+ derived from one inputs.json (shape: template-inputs.schema.json), drives
5
+ Chrome headless to produce a 4-page A4 PDF, and asserts page count == 4.
6
+
7
+ Usage:
8
+ python3 render.py inputs.json
9
+ """
10
+ from __future__ import annotations
11
+ import html, json, re, shutil, subprocess, sys
12
+ from pathlib import Path
13
+
14
+
15
+ def _money(n: int) -> str:
16
+ return f"£{int(n):,}"
17
+
18
+
19
+ def _sold_comps_rows(rows: list[dict]) -> str:
20
+ if not rows:
21
+ return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
22
+ return "".join(
23
+ f"<tr><td>{html.escape(r['date'])}</td>"
24
+ f"<td>{html.escape(r['address'])}</td>"
25
+ f"<td>{html.escape(r['type'])}</td>"
26
+ f"<td class=\"num\">{_money(r['price'])}</td></tr>"
27
+ for r in rows[:6]
28
+ )
29
+
30
+
31
+ def _asking_comps_tiles(rows: list[dict]) -> str:
32
+ if not rows:
33
+ return '<div class="muted">No live asking comps in the sample.</div>'
34
+ out = []
35
+ for r in rows[:4]:
36
+ meta_bits = []
37
+ if r.get("beds"):
38
+ meta_bits.append(f"{r['beds']} bed")
39
+ if r.get("type"):
40
+ meta_bits.append(html.escape(r["type"]))
41
+ meta = " · ".join(meta_bits) or "&nbsp;"
42
+ out.append(
43
+ f'<div class="ask-tile">'
44
+ f'<div class="addr">{html.escape(r["address"])}</div>'
45
+ f'<div class="price">{_money(r["price"])}</div>'
46
+ f'<div class="meta">{meta}</div>'
47
+ f"</div>"
48
+ )
49
+ return "".join(out)
50
+
51
+
52
+ def _crime_rows(rows: list[dict]) -> str:
53
+ if not rows:
54
+ return '<div class="muted">No crime data.</div>'
55
+ top = sorted(rows, key=lambda r: r["count"], reverse=True)[:6]
56
+ cap = max((r["count"] for r in top), default=1) or 1
57
+ return "".join(
58
+ f'<div class="dist-row">'
59
+ f'<div class="name">{html.escape(r["category"])}</div>'
60
+ f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{int(100 * r["count"] / cap)}%"></span></div>'
61
+ f'<div class="pct">{r["count"]}</div>'
62
+ f"</div>"
63
+ for i, r in enumerate(top)
64
+ )
65
+
66
+
67
+ def _band_rows(rows: list[dict]) -> str:
68
+ if not rows:
69
+ return '<tr><td colspan="2" class="muted">No banding data.</td></tr>'
70
+ return "".join(
71
+ f"<tr><td>Band {html.escape(r['band'])}</td>"
72
+ f"<td class=\"num\">{_money(r['amount'])}</td></tr>"
73
+ for r in rows
74
+ )
75
+
76
+
77
+ def _type_dist_rows(rows: list[dict]) -> str:
78
+ if not rows:
79
+ return '<div class="muted">No property-type mix data.</div>'
80
+ ordered = sorted(rows, key=lambda r: float(r["pct"]), reverse=True)
81
+ return "".join(
82
+ f'<div class="dist-row">'
83
+ f'<div class="name">{html.escape(r["name"])}</div>'
84
+ f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{float(r["pct"]):.0f}%"></span></div>'
85
+ f'<div class="pct">{float(r["pct"]):.1f}%</div>'
86
+ f"</div>"
87
+ for i, r in enumerate(ordered)
88
+ )
89
+
90
+
91
+ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
92
+ """Two-line sparkline in a 1000x380 viewBox SVG, no external dependencies."""
93
+ W, H = 1000, 380
94
+ pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
95
+ iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
96
+
97
+ def _line(series, key, css_pl, css_pt):
98
+ if not series:
99
+ return ""
100
+ ys = [float(p[key]) for p in series]
101
+ ymin, ymax = min(ys), max(ys)
102
+ if ymax == ymin:
103
+ ymax = ymin + 1
104
+ n = len(series)
105
+ pts = []
106
+ circles = []
107
+ labels = []
108
+ for i, p in enumerate(series):
109
+ x = pad_l + (i * iw / max(1, n - 1))
110
+ y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
111
+ pts.append(f"{x:.1f},{y:.1f}")
112
+ circles.append(f'<circle class="{css_pt}" cx="{x:.1f}" cy="{y:.1f}" r="3"/>')
113
+ labels.append(
114
+ f'<text x="{x:.1f}" y="{H - 18}" text-anchor="middle">{html.escape(p["label"])}</text>'
115
+ )
116
+ return (
117
+ f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
118
+ + "".join(circles)
119
+ + "".join(labels)
120
+ )
121
+
122
+ axis = (
123
+ f'<line class="axis" x1="{pad_l}" y1="{pad_t + ih}" x2="{W - pad_r}" y2="{pad_t + ih}"/>'
124
+ )
125
+ body = axis + _line(price_series, "price", "pl", "pt") + _line(psf_series, "psf", "pl-psf", "pt-psf")
126
+ return (
127
+ f'<svg class="spark" viewBox="0 0 {W} {H}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">{body}</svg>'
128
+ )
129
+
130
+
131
+ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
132
+ template_path = template_path or (Path(__file__).parent / "template.html")
133
+ tmpl = template_path.read_text()
134
+
135
+ v = inputs["valuation"]
136
+ a = inputs["area"]
137
+ d = inputs["demand_trend"]
138
+ r = d["rental"]
139
+
140
+ slots = {
141
+ "address": inputs["address"],
142
+ "postcode": inputs["postcode"],
143
+ "generated": inputs["generated"],
144
+
145
+ "kpi_avg_asking": _money(v["avg_asking"]),
146
+ "kpi_asking_psf": _money(v["asking_psf"]),
147
+ "kpi_avg_sold": _money(v["avg_sold"]),
148
+ "kpi_sold_psf": _money(v["sold_psf"]),
149
+ "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
150
+ "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
151
+
152
+ "area_headline": a["headline"],
153
+ "kpi_crime": a["crime_rating"],
154
+ "kpi_flood": a["flood_risk"],
155
+ "kpi_band_d": _money(a["council_tax_band_d"]),
156
+ "kpi_population": f"{int(a['population']):,}",
157
+ "crime_rows": _crime_rows(a.get("crime_breakdown", [])),
158
+ "band_rows": _band_rows(a.get("council_tax_bands", [])),
159
+
160
+ "kpi_demand_rating": d["demand_rating"],
161
+ "kpi_dom": str(d["dom"]),
162
+ "kpi_turnover": f"{int(d['turnover_pct'])}%",
163
+ "kpi_for_sale": str(d["for_sale"]),
164
+ "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
165
+
166
+ "rental_rating": r["demand_rating"],
167
+ "rental_dom": str(r["dom"]),
168
+ "rental_turnover": f"{int(r['turnover_pct'])}%",
169
+ "rental_for_rent": str(r["for_rent"]),
170
+
171
+ "type_dist_rows": _type_dist_rows(d.get("type_dist", [])),
172
+ }
173
+
174
+ # Escape free-form text slots that aren't already HTML fragments.
175
+ text_slots = {"address", "postcode", "generated", "area_headline", "kpi_crime", "kpi_flood",
176
+ "kpi_demand_rating", "rental_rating"}
177
+ for k in text_slots:
178
+ slots[k] = html.escape(slots[k])
179
+
180
+ out = tmpl
181
+ for k, val in slots.items():
182
+ out = out.replace(f"<!-- REPLACE: {k} -->", val)
183
+
184
+ leftovers = re.findall(r"<!-- REPLACE: (\w+) -->", out)
185
+ if leftovers:
186
+ raise RuntimeError(
187
+ "template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
188
+ )
189
+
190
+ out_dir.mkdir(parents=True, exist_ok=True)
191
+ out_path = out_dir / "index.html"
192
+ out_path.write_text(out)
193
+ return out_path
194
+
195
+
196
+ def render_pdf(out_dir: Path, pdf_name: str) -> Path:
197
+ chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
198
+ if not Path(chrome).exists():
199
+ raise FileNotFoundError(
200
+ "Chrome not found at " + chrome + " — install Chrome or override the path."
201
+ )
202
+ pdf_path = out_dir / pdf_name
203
+ subprocess.run([
204
+ chrome, "--headless", "--disable-gpu", "--no-pdf-header-footer",
205
+ f"--print-to-pdf={pdf_path.resolve()}", "--virtual-time-budget=30000",
206
+ f"file://{(out_dir / 'index.html').resolve()}",
207
+ ], check=True, capture_output=True)
208
+ return pdf_path
209
+
210
+
211
+ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
212
+
213
+
214
+ def _count_pages(pdf_path: Path) -> int:
215
+ """Prefer pdfinfo (handles compressed object streams); fall back to a
216
+ raw `/Type /Page` byte scan when pdfinfo is not installed. The fallback
217
+ is correct for current Chrome `--print-to-pdf` output (no `/ObjStm`)
218
+ but would silently undercount if a future Chrome switches to compressed
219
+ page-object streams — hence pdfinfo first when available.
220
+ """
221
+ pdfinfo = shutil.which("pdfinfo")
222
+ if pdfinfo:
223
+ result = subprocess.run(
224
+ [pdfinfo, str(pdf_path)], check=True, capture_output=True, text=True,
225
+ )
226
+ for line in result.stdout.splitlines():
227
+ if line.startswith("Pages:"):
228
+ return int(line.split(":", 1)[1].strip())
229
+ raise RuntimeError(f"pdfinfo emitted no Pages line for {pdf_path}")
230
+ return len(_PAGE_OBJ_RE.findall(pdf_path.read_bytes()))
231
+
232
+
233
+ def assert_page_count(pdf_path: Path, expected: int) -> int:
234
+ """Assert PDF page count == expected; raise RuntimeError otherwise.
235
+
236
+ Synchronous and reliable — does not depend on Spotlight (`mdls` returns
237
+ `(null)` until indexing finishes, which is async). Operators can still
238
+ verify externally with `mdls -name kMDItemNumberOfPages <pdf>` once
239
+ Spotlight catches up.
240
+ """
241
+ n = _count_pages(pdf_path)
242
+ if n != expected:
243
+ raise RuntimeError(
244
+ f"page-count mismatch: expected {expected}, got {n} ({pdf_path}) — "
245
+ "tighten panel padding for the overflowing page"
246
+ )
247
+ return n
248
+
249
+
250
+ def main():
251
+ if len(sys.argv) < 2:
252
+ print("Usage: render.py <inputs.json>", file=sys.stderr)
253
+ sys.exit(2)
254
+ inputs = json.loads(Path(sys.argv[1]).read_text())
255
+ out_dir = Path(inputs["out_dir"]).expanduser()
256
+ base = inputs["filename_stem"]
257
+ render(inputs, out_dir)
258
+ pdf = render_pdf(out_dir, f"{base}.pdf")
259
+ pages = assert_page_count(pdf, 4)
260
+ print(f"HTML → {out_dir/'index.html'}")
261
+ print(f"PDF → {pdf} ({pages} pages)")
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()
@@ -0,0 +1,143 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "property-preval inputs",
4
+ "description": "Single-source contract for render.py. Mirrors the 12 property-data MCP responses for one full UK address.",
5
+ "type": "object",
6
+ "required": ["out_dir", "filename_stem", "address", "postcode", "generated", "valuation", "area", "demand_trend"],
7
+ "properties": {
8
+ "out_dir": { "type": "string", "description": "Absolute path to the output directory (created if missing)." },
9
+ "filename_stem": { "type": "string", "description": "PDF filename stem; .pdf appended by the renderer." },
10
+ "address": { "type": "string", "description": "Full UK address as the operator gave it." },
11
+ "postcode": { "type": "string", "description": "Postcode derived from the address, e.g. 'CM23 2UJ'." },
12
+ "generated": { "type": "string", "description": "Human date, e.g. '19 May 2026'." },
13
+ "valuation": {
14
+ "type": "object",
15
+ "required": ["avg_asking", "asking_psf", "avg_sold", "sold_psf", "sold_comps", "asking_comps"],
16
+ "properties": {
17
+ "avg_asking": { "type": "integer", "description": "Mean asking £ (from property-data-prices)." },
18
+ "asking_psf": { "type": "integer", "description": "Asking £/sqft (from property-data-prices-per-sqf)." },
19
+ "avg_sold": { "type": "integer", "description": "Mean sold £ (from property-data-sold-prices)." },
20
+ "sold_psf": { "type": "integer", "description": "Sold £/sqft (from property-data-sold-prices-per-sqf)." },
21
+ "sold_range": { "type": "array", "items": { "type": "integer" }, "minItems": 2, "maxItems": 2, "description": "Optional 70pc range [low, high]." },
22
+ "sold_comps": {
23
+ "type": "array",
24
+ "description": "Nearest sold transactions (≤ 6 rows in the table).",
25
+ "items": {
26
+ "type": "object",
27
+ "required": ["date", "address", "type", "price"],
28
+ "properties": {
29
+ "date": { "type": "string" },
30
+ "address": { "type": "string" },
31
+ "type": { "type": "string" },
32
+ "price": { "type": "integer" }
33
+ }
34
+ }
35
+ },
36
+ "asking_comps": {
37
+ "type": "array",
38
+ "description": "Live asking comps (≤ 4 tiles).",
39
+ "items": {
40
+ "type": "object",
41
+ "required": ["address", "price"],
42
+ "properties": {
43
+ "address": { "type": "string" },
44
+ "price": { "type": "integer" },
45
+ "beds": { "type": ["integer", "null"] },
46
+ "type": { "type": ["string", "null"] }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ },
52
+ "area": {
53
+ "type": "object",
54
+ "required": ["crime_rating", "flood_risk", "council_tax_band_d", "population", "crime_breakdown", "council_tax_bands", "headline"],
55
+ "properties": {
56
+ "crime_rating": { "type": "string", "description": "e.g. 'Below average'." },
57
+ "flood_risk": { "type": "string", "description": "e.g. 'Very low'." },
58
+ "council_tax_band_d": { "type": "integer", "description": "Annual £ at band D." },
59
+ "population": { "type": "integer", "description": "Local area population." },
60
+ "crime_breakdown": {
61
+ "type": "array",
62
+ "items": {
63
+ "type": "object",
64
+ "required": ["category", "count"],
65
+ "properties": {
66
+ "category": { "type": "string" },
67
+ "count": { "type": "integer" }
68
+ }
69
+ }
70
+ },
71
+ "council_tax_bands": {
72
+ "type": "array",
73
+ "description": "Band → annual £ rows.",
74
+ "items": {
75
+ "type": "object",
76
+ "required": ["band", "amount"],
77
+ "properties": {
78
+ "band": { "type": "string" },
79
+ "amount": { "type": "integer" }
80
+ }
81
+ }
82
+ },
83
+ "headline": { "type": "string", "description": "One-sentence area read." }
84
+ }
85
+ },
86
+ "demand_trend": {
87
+ "type": "object",
88
+ "required": ["demand_rating", "dom", "turnover_pct", "for_sale", "growth_series", "growth_psf_series", "rental", "type_dist"],
89
+ "properties": {
90
+ "demand_rating": { "type": "string", "description": "e.g. 'Balanced market'." },
91
+ "dom": { "type": "integer", "description": "Days on market." },
92
+ "turnover_pct": { "type": "integer", "description": "Turnover percent per month." },
93
+ "for_sale": { "type": "integer", "description": "Current for-sale count." },
94
+ "growth_series": {
95
+ "type": "array",
96
+ "description": "7-year £ series, oldest first.",
97
+ "items": {
98
+ "type": "object",
99
+ "required": ["label", "price"],
100
+ "properties": {
101
+ "label": { "type": "string" },
102
+ "price": { "type": "integer" }
103
+ }
104
+ }
105
+ },
106
+ "growth_psf_series": {
107
+ "type": "array",
108
+ "description": "7-year £/sqft series, oldest first.",
109
+ "items": {
110
+ "type": "object",
111
+ "required": ["label", "psf"],
112
+ "properties": {
113
+ "label": { "type": "string" },
114
+ "psf": { "type": "integer" }
115
+ }
116
+ }
117
+ },
118
+ "rental": {
119
+ "type": "object",
120
+ "required": ["demand_rating", "dom", "turnover_pct", "for_rent"],
121
+ "properties": {
122
+ "demand_rating": { "type": "string" },
123
+ "dom": { "type": "integer" },
124
+ "turnover_pct": { "type": "integer" },
125
+ "for_rent": { "type": "integer" }
126
+ }
127
+ },
128
+ "type_dist": {
129
+ "type": "array",
130
+ "description": "Property-type mix doughnut rows.",
131
+ "items": {
132
+ "type": "object",
133
+ "required": ["name", "pct"],
134
+ "properties": {
135
+ "name": { "type": "string" },
136
+ "pct": { "type": "number" }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }