@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,78 @@
1
+ ---
2
+ name: property-preval
3
+ description: Use when an operator asks for a pre-valuation pack for a single UK property — input is one full UK address, output is a 4-page A4 PDF covering valuation, area, and demand/trend. Triggers include "preval <address>", "pre-valuation for <address>", "what's <address> worth", "valuation pack for <address>", "market and area report for <address>".
4
+ ---
5
+
6
+ # Property Preval
7
+
8
+ Outcome: turn **one full UK address** into a 4-page A4 pre-valuation PDF the operator can send to a vendor before the appraisal visit. The skill owns the fan-out and the render; the operator supplies only the address.
9
+
10
+ ## Input
11
+
12
+ A single string — the full UK address, ending in a valid UK postcode. Examples:
13
+
14
+ - `47 Phoenix Drive, Bishop's Stortford, CM23 2UJ`
15
+ - `Flat 3, 122 Kensington High Street, London, W8 4SG`
16
+
17
+ The skill derives the postcode by matching the UK postcode regex on the final token group. If no postcode is matched, the skill stops and asks the operator for one.
18
+
19
+ ## Output
20
+
21
+ A directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
22
+
23
+ - `index.html` — single self-contained file.
24
+ - `<slug>-<date>.pdf` — exactly 4 A4 pages. If `mdls -name kMDItemNumberOfPages` returns ≠ 4, the renderer raises rather than ship an overflowing report.
25
+
26
+ Return the PDF path to the operator.
27
+
28
+ ## Tools the skill calls (in order)
29
+
30
+ Fan-out is fixed at **12** `real-agent-property-data` MCP tools, keyed on the derived postcode (and address attributes where the tool needs them):
31
+
32
+ 1. `property-data-prices` — asking £ distribution + raw asking comps.
33
+ 2. `property-data-prices-per-sqf` — asking £/sqft.
34
+ 3. `property-data-sold-prices` — Land Registry sold transactions + raw sold comps.
35
+ 4. `property-data-sold-prices-per-sqf` — achieved £/sqft.
36
+ 5. `property-data-growth` — 7y price series.
37
+ 6. `property-data-growth-psf` — 7y £/sqft series.
38
+ 7. `property-data-demand` — for-sale, sales/mo, turnover, MoI, DoM, demand rating.
39
+ 8. `property-data-demand-rent` — rental-side demand pillar.
40
+ 9. `property-data-property-types` — mix doughnut (detached / semi / terraced / flat).
41
+ 10. `property-data-crime` — area crime rating + breakdown.
42
+ 11. `property-data-flood-risk` — area flood risk (full postcode only).
43
+ 12. `property-data-council-tax` — band D cost + per-property bands (full postcode only).
44
+
45
+ ## Tools the skill deliberately does NOT call
46
+
47
+ - `property-data-address-match-uprn` — UPRN is CRM hygiene, not preval data.
48
+ - `property-data-planning-applications` — planning is a separate dev-precedent report.
49
+
50
+ Both remain available via the underlying `property-data` skill; preval omits them to keep the output focused and the credit cost at 12 per pack.
51
+
52
+ ## How to execute
53
+
54
+ 1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop and ask if no match.
55
+ 2. Run the 12 fan-out tools in the order above. Any `Error: key-not-registered` aborts the run — surface the registration prompt and stop.
56
+ 3. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
57
+ 4. Run `python3 references/render.py inputs.json`. The renderer fills the template, writes the HTML, drives Chrome `--print-to-pdf`, and asserts page count = 4.
58
+ 5. Return the PDF path.
59
+
60
+ ## Observability
61
+
62
+ Emit at start: `[preval] start address="<addr>" postcode=<pc>`.
63
+ Emit at done: `[preval] done pdf=<path> pages=<n> ms=<n>`.
64
+
65
+ Each of the 12 fan-out calls produces its own `[property-data] tool=<name>` line in `server.log` (inherited contract — no new instrumentation).
66
+
67
+ ## Failure signatures
68
+
69
+ - `pages=5` (or anything ≠ 4) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f 5 -l 5 <pdf>` to see which content bled, then trim that panel's padding.
70
+ - `start` with no matching `done` within 60s → renderer crashed. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
71
+ - Wrong postcode derivation → check `grep '\[preval\] start' server.log | awk -F'postcode=' '{print $2}' | sort -u`; the regex must keep the inward part of the postcode (e.g. `CM23 2UJ`, not `CM23`).
72
+
73
+ ## Out of scope
74
+
75
+ - Multi-address batch preval (CSV in → many PDFs out) — separate skill if needed.
76
+ - Loop CRM auto-publish of the PDF onto a contact's record — separate skill.
77
+ - A hero image on the cover via `property-socials` — preval cover is text-only by design.
78
+ - Caching of MCP responses — 12 credits per pack is acceptable; cost optimisation is a Task 144B concern.
@@ -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
+ }