@rubytech/create-maxy-code 0.1.61 → 0.1.63

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 (148) hide show
  1. package/dist/__tests__/installer-specialist-registration.test.js +112 -0
  2. package/dist/index.js +22 -0
  3. package/dist/specialist-registration.js +78 -0
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/PLUGIN.md +22 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +50 -13
  7. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +22 -21
  8. package/payload/platform/plugins/contacts/PLUGIN.md +9 -0
  9. package/payload/platform/plugins/docs/references/admin-session.md +55 -7
  10. package/payload/platform/plugins/email/PLUGIN.md +9 -0
  11. package/payload/platform/plugins/epc/PLUGIN.md +5 -0
  12. package/payload/platform/plugins/loop/PLUGIN.md +27 -0
  13. package/payload/platform/plugins/memory/PLUGIN.md +37 -0
  14. package/payload/platform/plugins/memory/mcp/dist/index.js +3 -3
  15. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/live-schema-source.test.js +137 -75
  17. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/live-schema-source.test.js.map +1 -1
  18. package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.d.ts +0 -17
  19. package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.d.ts.map +1 -1
  20. package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.js +32 -4
  21. package/payload/platform/plugins/memory/mcp/dist/lib/live-schema-source.js.map +1 -1
  22. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts +65 -0
  23. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts.map +1 -0
  24. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js +182 -0
  25. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js.map +1 -0
  26. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection.test.js +8 -8
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection.test.js.map +1 -1
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.d.ts +2 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.d.ts.map +1 -0
  30. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.js +241 -0
  31. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/log-ingest.test.js.map +1 -0
  32. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.d.ts.map +1 -1
  33. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.js +9 -0
  34. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-derive-insights.js.map +1 -1
  35. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.d.ts +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.d.ts.map +1 -1
  37. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.js +9 -1
  38. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-archive-enrich-rejection.js.map +1 -1
  39. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.d.ts.map +1 -1
  40. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.js +10 -2
  41. package/payload/platform/plugins/memory/mcp/dist/tools/conversation-memory-expunge.js.map +1 -1
  42. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.d.ts +1 -1
  43. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.d.ts.map +1 -1
  44. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.js +5 -1
  45. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-add.js.map +1 -1
  46. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.d.ts +1 -1
  47. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.d.ts.map +1 -1
  48. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.js +5 -1
  49. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-denylist-remove.js.map +1 -1
  50. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -1
  51. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +4 -0
  52. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -1
  53. package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.d.ts.map +1 -1
  54. package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.js +4 -0
  55. package/payload/platform/plugins/memory/mcp/dist/tools/memory-classify.js.map +1 -1
  56. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.d.ts.map +1 -1
  57. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js +4 -0
  58. package/payload/platform/plugins/memory/mcp/dist/tools/memory-delete.js.map +1 -1
  59. package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.d.ts.map +1 -1
  60. package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.js +4 -0
  61. package/payload/platform/plugins/memory/mcp/dist/tools/memory-edit-attachment.js.map +1 -1
  62. package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.d.ts.map +1 -1
  63. package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.js +4 -0
  64. package/payload/platform/plugins/memory/mcp/dist/tools/memory-empty-trash.js.map +1 -1
  65. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.d.ts.map +1 -1
  66. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.js +4 -0
  67. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-extract.js.map +1 -1
  68. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.d.ts.map +1 -1
  69. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js +4 -0
  70. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest-web.js.map +1 -1
  71. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  72. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +5 -0
  73. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  74. package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.d.ts.map +1 -1
  75. package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.js +4 -0
  76. package/payload/platform/plugins/memory/mcp/dist/tools/memory-reindex.js.map +1 -1
  77. package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.d.ts.map +1 -1
  78. package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.js +4 -0
  79. package/payload/platform/plugins/memory/mcp/dist/tools/memory-rename-attachment.js.map +1 -1
  80. package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.d.ts.map +1 -1
  81. package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.js +4 -0
  82. package/payload/platform/plugins/memory/mcp/dist/tools/memory-restore.js.map +1 -1
  83. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  84. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +5 -0
  85. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  86. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  87. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +6 -0
  88. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  89. package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.d.ts.map +1 -1
  90. package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.js +4 -0
  91. package/payload/platform/plugins/memory/mcp/dist/tools/profile-delete.js.map +1 -1
  92. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
  93. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +4 -0
  94. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  95. package/payload/platform/plugins/memory/mcp/vitest.config.ts +10 -3
  96. package/payload/platform/plugins/outlook/PLUGIN.md +7 -0
  97. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
  98. package/payload/platform/plugins/preval/PLUGIN.md +22 -12
  99. package/payload/platform/plugins/preval/hooks/preval-prompt-required.sh +140 -0
  100. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +77 -19
  101. package/payload/platform/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
  102. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +390 -47
  103. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +32 -21
  104. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +0 -1
  105. package/payload/platform/plugins/property-data/PLUGIN.md +22 -0
  106. package/payload/platform/plugins/replicate/PLUGIN.md +3 -0
  107. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
  108. package/payload/platform/plugins/tasks/PLUGIN.md +14 -0
  109. package/payload/platform/plugins/telegram/PLUGIN.md +3 -0
  110. package/payload/platform/plugins/waitlist/PLUGIN.md +7 -0
  111. package/payload/platform/plugins/whatsapp/PLUGIN.md +12 -0
  112. package/payload/platform/plugins/workflows/PLUGIN.md +8 -0
  113. package/payload/platform/services/claude-session-manager/dist/index.js +39 -1
  114. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  115. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -0
  116. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  117. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +148 -41
  118. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  119. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +23 -0
  120. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -0
  121. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +141 -0
  122. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -0
  123. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +13 -2
  124. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
  125. package/payload/platform/services/claude-session-manager/dist/tool-surface.js +135 -37
  126. package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
  127. package/payload/premium-plugins/real-agent/agents/compliance.md +1 -1
  128. package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +5 -0
  129. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +27 -0
  130. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
  131. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +22 -12
  132. package/payload/premium-plugins/real-agent/plugins/preval/hooks/preval-prompt-required.sh +140 -0
  133. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +77 -19
  134. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
  135. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +390 -47
  136. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +32 -21
  137. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +0 -1
  138. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +22 -0
  139. package/payload/server/public/assets/{admin-DhN3G8W7.js → admin-ymReS1mN.js} +1 -1
  140. package/payload/server/public/assets/{data-B2ZVXOcE.js → data-D6U2e85B.js} +1 -1
  141. package/payload/server/public/assets/{graph-BAMGPHrK.js → graph-C274PN27.js} +1 -1
  142. package/payload/server/public/assets/{graph-labels-D9eBbvxo.js → graph-labels-DQFzd1FY.js} +1 -1
  143. package/payload/server/public/assets/{page-DjdVMWCz.js → page-D-OLjort.js} +1 -1
  144. package/payload/server/public/assets/{page-CV27Al6Z.js → page-DFFt9_Q0.js} +1 -1
  145. package/payload/server/public/data.html +3 -3
  146. package/payload/server/public/graph.html +3 -3
  147. package/payload/server/public/index.html +4 -4
  148. package/payload/server/server.js +1 -1
@@ -4,11 +4,16 @@ Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
4
4
  derived from one inputs.json (shape: template-inputs.schema.json), drives
5
5
  Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
6
6
 
7
+ Fails loud on any missing / unverifiable load-bearing input. No silent
8
+ fallback modes — see _assert_inputs. Task 157 layers provenance gates
9
+ (market_summary.origin, agent_listings[].origin, agent_overrides) on top
10
+ of 156's load-bearing-input checks.
11
+
7
12
  Usage:
8
13
  python3 render.py inputs.json
9
14
  """
10
15
  from __future__ import annotations
11
- import html, json, re, shutil, subprocess, sys
16
+ import html, json, re, shutil, subprocess, sys, time
12
17
  from pathlib import Path
13
18
 
14
19
 
@@ -214,20 +219,11 @@ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
214
219
  def _valuation_block(subject: dict, valuation: dict) -> str:
215
220
  """Build the page-2 valuation panel: headline range, adjustments, final range.
216
221
 
217
- Falls back to the 'requires floor area' state when subject.sqft is missing.
222
+ The three EPC certificate fields are asserted non-null upstream by
223
+ _assert_inputs — no fallback panel here. sqft is derived from
224
+ total_floor_area_m2 via M2_TO_SQFT.
218
225
  """
219
- sqft = subject.get("sqft")
220
- if not sqft:
221
- return (
222
- '<div class="val-panel"><div class="val-empty">'
223
- "Estimate requires floor area.<br>"
224
- "<span style=\"font-size:10pt;color:var(--text-muted)\">"
225
- f"Benchmark: sold £/sqft = {_money(valuation['sold_psf'])}, "
226
- f"asking £/sqft = {_money(valuation['asking_psf'])}. "
227
- "Paste subject.sqft to compute the range."
228
- "</span></div></div>"
229
- )
230
-
226
+ sqft = _derived_sqft(subject)
231
227
  low_psf = valuation["sold_psf_low"]
232
228
  mid_psf = valuation["sold_psf"]
233
229
  high_psf = valuation["sold_psf_high"]
@@ -276,32 +272,246 @@ def _valuation_block(subject: dict, valuation: dict) -> str:
276
272
  return '<div class="val-panel">' + "".join(rows) + "</div>" + final
277
273
 
278
274
 
275
+ def _derived_sqft(subject: dict) -> int:
276
+ return round(float(subject["total_floor_area_m2"]) * M2_TO_SQFT)
277
+
278
+
279
279
  def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
280
- sqft = subject.get("sqft")
281
- m2 = subject.get("sqft_m2")
282
- if sqft:
283
- sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
284
- return f"{int(sqft):,}", sub
285
- return "—", "No EPC on file"
280
+ sqft = _derived_sqft(subject)
281
+ m2 = float(subject["total_floor_area_m2"])
282
+ return f"{sqft:,}", f"≈ {m2:.0f} m² · from EPC certificate"
286
283
 
287
284
 
288
285
  def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
289
- current = subject.get("epc_current")
290
- potential = subject.get("epc_potential")
291
- lodgement = subject.get("epc_lodgement")
292
- if not current:
293
- return "—", "No EPC on file"
294
- sub_bits = []
295
- if potential:
296
- sub_bits.append(f"potential {potential}")
297
- if lodgement:
298
- sub_bits.append(f"lodged {lodgement}")
299
- return current, " · ".join(sub_bits) or "EPC register"
286
+ current = subject["current_energy_rating"]
287
+ lodgement = subject["lodgement_date"]
288
+ return current, f"lodged {lodgement}"
289
+
290
+
291
+ # ---------------- input assertions ----------------
292
+
293
+ class PrevalAbort(RuntimeError):
294
+ """Raised when a load-bearing input is missing or unverifiable.
295
+
296
+ .cause is the short slug logged as `reason=<cause>` and shown in the
297
+ operator's remediation hint.
298
+ """
299
+ def __init__(self, cause: str, hint: str):
300
+ super().__init__(f"{cause}: {hint}")
301
+ self.cause = cause
302
+ self.hint = hint
303
+
304
+
305
+ _STREET_TOKEN_STOPWORDS = {
306
+ "the", "a", "an", "of", "and",
307
+ "road", "street", "drive", "lane", "avenue", "close", "court",
308
+ "place", "way", "gardens", "crescent", "hill", "rise", "grove",
309
+ "terrace", "mews", "square", "park", "walk", "row",
310
+ "rd", "st", "dr", "ln", "ave", "cl", "ct", "pl",
311
+ }
312
+
313
+
314
+ def _street_token(address: str) -> str | None:
315
+ """First non-stopword token from the address's street component.
316
+
317
+ Used to detect cover_hero photos sourced from a sister property folder.
318
+ "9 Phoenix Drive, Bishop's Stortford, CM23 2UJ" → "phoenix".
319
+ """
320
+ if not address:
321
+ return None
322
+ first = address.split(",")[0].strip().lower()
323
+ for raw in re.split(r"[^a-z]+", first):
324
+ if raw and not raw.isdigit() and raw not in _STREET_TOKEN_STOPWORDS:
325
+ return raw
326
+ return None
327
+
328
+
329
+ def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
330
+ """Existence + dimension check. Symlinks pointing into another property's
331
+ folder are rejected (cover-hero cross-contamination guard)."""
332
+ p = Path(path_str).expanduser()
333
+ if not p.exists():
334
+ raise PrevalAbort(
335
+ "subject-cover-hero-missing",
336
+ f"cover_hero path does not exist: {p}",
337
+ )
338
+ if p.is_symlink():
339
+ target = p.resolve()
340
+ parts = target.parts
341
+ if "properties" in parts:
342
+ raise PrevalAbort(
343
+ "subject-cover-hero-symlinked-foreign",
344
+ f"cover_hero is a symlink into another property's folder: {target}",
345
+ )
346
+ w, h = _image_dimensions(p)
347
+ if w is None or h is None:
348
+ raise PrevalAbort(
349
+ "subject-cover-hero-unreadable",
350
+ f"cover_hero is not a readable PNG or JPEG: {p}",
351
+ )
352
+ if w < min_w or h < min_h:
353
+ raise PrevalAbort(
354
+ "subject-cover-hero-too-small",
355
+ f"cover_hero is {w}×{h}, below the {min_w}×{min_h} minimum: {p}",
356
+ )
357
+
358
+
359
+ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
360
+ """Return (width, height) for PNG or JPEG. None on any failure."""
361
+ try:
362
+ with p.open("rb") as f:
363
+ head = f.read(24)
364
+ if len(head) < 24:
365
+ return None, None
366
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
367
+ w = int.from_bytes(head[16:20], "big")
368
+ h = int.from_bytes(head[20:24], "big")
369
+ return w, h
370
+ if head[:2] == b"\xff\xd8":
371
+ f.seek(2)
372
+ while True:
373
+ b = f.read(1)
374
+ while b and b != b"\xff":
375
+ b = f.read(1)
376
+ marker = f.read(1)
377
+ if not marker:
378
+ return None, None
379
+ if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
380
+ f.read(3)
381
+ h = int.from_bytes(f.read(2), "big")
382
+ w = int.from_bytes(f.read(2), "big")
383
+ return w, h
384
+ seg_len = int.from_bytes(f.read(2), "big")
385
+ f.seek(seg_len - 2, 1)
386
+ return None, None
387
+ except OSError:
388
+ return None, None
389
+
390
+
391
+ def _assert_inputs(inputs: dict) -> None:
392
+ """Fail loud on any missing or unverifiable load-bearing input.
393
+
394
+ Task 156 causes (load-bearing inputs):
395
+ subject-cover-hero-*, brand-unresolved,
396
+ agent-listings-empty, market-summary-too-thin.
397
+
398
+ Task 157 causes (provenance — agent didn't invent the input):
399
+ cover-hero-not-subject, agent-listings-invented,
400
+ market-summary-invented, brand-invented.
401
+
402
+ Task 163 cause (EPC certificate paste-in completes 157's mandate):
403
+ epc-certificate-not-supplied — any of total_floor_area_m2,
404
+ current_energy_rating, lodgement_date is null/missing.
405
+ """
406
+ subject = inputs.get("subject") or {}
407
+ address = inputs.get("address") or ""
408
+ overrides = inputs.get("agent_overrides") or {}
409
+
410
+ # --- 163: EPC certificate fields are operator paste-in ---
411
+ missing_epc = [
412
+ f for f in ("total_floor_area_m2", "current_energy_rating", "lodgement_date")
413
+ if not subject.get(f)
414
+ ]
415
+ if missing_epc:
416
+ raise PrevalAbort(
417
+ "epc-certificate-not-supplied",
418
+ f"subject is missing EPC certificate field(s) {missing_epc}. "
419
+ "Paste total_floor_area_m2, current_energy_rating, and lodgement_date "
420
+ "from the EPC certificate into subject before re-invoking. "
421
+ "No mid-run prompt — the operator supplies the certificate before invocation.",
422
+ )
423
+
424
+ hero = subject.get("cover_hero")
425
+ if not hero:
426
+ raise PrevalAbort(
427
+ "subject-cover-hero-missing",
428
+ "subject.cover_hero is required for the cover page.",
429
+ )
430
+ if not hero.startswith(("http://", "https://", "data:")):
431
+ _assert_path_image(hero)
432
+
433
+ brand = inputs.get("brand") or {}
434
+ required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
435
+ missing_brand = [k for k in required_brand if not brand.get(k)]
436
+ if missing_brand:
437
+ raise PrevalAbort(
438
+ "brand-unresolved",
439
+ f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
440
+ )
441
+
442
+ if not inputs.get("agent_listings"):
443
+ raise PrevalAbort(
444
+ "agent-listings-empty",
445
+ "agent_listings is empty — paste at least one recent listing for the closing page.",
446
+ )
447
+
448
+ ms = inputs.get("market_summary") or {}
449
+ paras = ms.get("paragraphs") or []
450
+ if len(paras) < 2:
451
+ raise PrevalAbort(
452
+ "market-summary-too-thin",
453
+ f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
454
+ )
455
+
456
+ # --- 157: provenance gates ---
457
+ if hero and not hero.startswith(("http://", "https://", "data:")):
458
+ hero_lc = hero.lower()
459
+ match = re.search(r"/properties?/([^/]+)/", hero_lc)
460
+ if match:
461
+ slug = match.group(1)
462
+ street = _street_token(address)
463
+ if street and street not in slug:
464
+ raise PrevalAbort(
465
+ "cover-hero-not-subject",
466
+ f"subject.cover_hero ({hero}) sits in a property folder whose "
467
+ f"slug ({slug}) does not contain the subject street token "
468
+ f"({street}). Prompt the operator for the subject's own photo.",
469
+ )
470
+
471
+ if not overrides.get("listings_status_curated"):
472
+ for listing in inputs.get("agent_listings") or []:
473
+ origin = (listing or {}).get("origin")
474
+ if not origin or origin == "operator":
475
+ continue
476
+ src = Path(str(origin)).expanduser()
477
+ if not src.exists():
478
+ continue
479
+ try:
480
+ source = json.loads(src.read_text())
481
+ except (OSError, json.JSONDecodeError):
482
+ continue
483
+ if source.get("status") in (None, "") and listing.get("status"):
484
+ raise PrevalAbort(
485
+ "agent-listings-invented",
486
+ f"agent_listings entry {listing.get('slug')} has status "
487
+ f"'{listing['status']}' but source {src} carries status:null. "
488
+ "Prompt the operator to confirm each status, then set "
489
+ "agent_overrides.listings_status_curated: true.",
490
+ )
491
+
492
+ if ms.get("origin") not in ("operator", "operator-edited-llm-stub"):
493
+ raise PrevalAbort(
494
+ "market-summary-invented",
495
+ "market_summary.origin must be 'operator' or 'operator-edited-llm-stub'. "
496
+ "Have the operator draft or accept the paragraphs before rendering — "
497
+ "the agent never invents the market narrative unprompted.",
498
+ )
499
+
500
+ if brand.get("name") and not overrides.get("brand_selected_by_operator"):
501
+ raise PrevalAbort(
502
+ "brand-invented",
503
+ f"brand.name ('{brand['name']}') was set without "
504
+ "agent_overrides.brand_selected_by_operator: true. Ask the operator "
505
+ "which estate agent is pitching for this instruction; the presence "
506
+ "of a DESIGN.md on disk does not authorise selection.",
507
+ )
300
508
 
301
509
 
302
510
  # ---------------- render ----------------
303
511
 
304
512
  def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
513
+ _assert_inputs(inputs)
514
+
305
515
  template_path = template_path or (Path(__file__).parent / "template.html")
306
516
  tmpl = template_path.read_text()
307
517
 
@@ -326,10 +536,9 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
326
536
 
327
537
  psf = d.get("sales_per_month")
328
538
  if psf is None:
329
- psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
539
+ psf = max(1, round(d["for_sale"] / 7))
330
540
 
331
541
  slots = {
332
- # branding
333
542
  "brand_name": brand["name"],
334
543
  "brand_tagline": brand["tagline"],
335
544
  "brand_primary": brand["primary"],
@@ -341,14 +550,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
341
550
  "wordmark_light": _wordmark(brand, "light", out_dir),
342
551
  "wordmark_dark": _wordmark(brand, "dark", out_dir),
343
552
 
344
- # cover + header
345
553
  "subject_hero_path": _hero_asset(subject, out_dir),
346
554
  "address": inputs["address"],
347
555
  "address_short": _short_address(inputs["address"]),
348
556
  "postcode": inputs["postcode"],
349
557
  "generated": inputs["generated"],
350
558
 
351
- # page 2 — subject valuation
352
559
  "kpi_subject_sqft": subject_sqft,
353
560
  "kpi_subject_sqft_sub": subject_sqft_sub,
354
561
  "kpi_subject_beds": str(subject.get("beds") or "—"),
@@ -359,7 +566,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
359
566
  "valuation_block": _valuation_block(subject, v),
360
567
  "subject_narrative": subject.get("narrative") or "",
361
568
 
362
- # page 3 — market context
363
569
  "summary_headline": ms["headline"],
364
570
  "summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
365
571
  "kpi_avg_asking": _money(v["avg_asking"]),
@@ -372,15 +578,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
372
578
  "demand_note": ms["demand_note"],
373
579
  "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
374
580
 
375
- # page 4 — comparables
376
581
  "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
377
582
  "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
378
583
 
379
- # page 5 — agent listings + sources
380
584
  "agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
381
585
  }
382
586
 
383
- # Escape only the plain-text slots; HTML fragments pass through untouched.
384
587
  text_slots = {
385
588
  "address", "address_short", "postcode", "generated",
386
589
  "brand_name", "brand_tagline",
@@ -427,11 +630,6 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
427
630
 
428
631
 
429
632
  def _count_pages(pdf_path: Path) -> int:
430
- """pdfinfo first (handles /ObjStm-compressed page-object streams), with a
431
- raw /Type /Page byte scan as fallback. The fallback is correct for current
432
- Chrome --print-to-pdf output but would undercount if Chrome ever switches
433
- to compressed page-object streams — hence pdfinfo first when present.
434
- """
435
633
  pdfinfo = shutil.which("pdfinfo")
436
634
  if pdfinfo:
437
635
  result = subprocess.run(
@@ -454,16 +652,161 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
454
652
  return n
455
653
 
456
654
 
655
+ # The 12 PropertyData fan-out tools the preval skill is contracted to call.
656
+ # Each tool the MCP server runs emits a `[property-data] tool=<name> ...` line
657
+ # (see propertydata-api.ts:92). The watchdog asserts every one of these names
658
+ # appears in the log within the 60s budget after `[preval] start`; a missing
659
+ # line means the fan-out silently dropped a call and the agent was about to
660
+ # render against an incomplete payload.
661
+ EXPECTED_PROPERTY_DATA_TOOLS = (
662
+ "property-data-prices",
663
+ "property-data-prices-per-sqf",
664
+ "property-data-sold-prices",
665
+ "property-data-sold-prices-per-sqf",
666
+ "property-data-growth",
667
+ "property-data-growth-psf",
668
+ "property-data-demand",
669
+ "property-data-demand-rent",
670
+ "property-data-property-types",
671
+ "property-data-crime",
672
+ "property-data-flood-risk",
673
+ "property-data-council-tax",
674
+ )
675
+
676
+
677
+ def _missing_property_data_tools(log_text: str) -> list[str]:
678
+ """Return EXPECTED_PROPERTY_DATA_TOOLS that have no `[property-data] tool=<name>` line in log_text."""
679
+ return [
680
+ name for name in EXPECTED_PROPERTY_DATA_TOOLS
681
+ if f"[property-data] tool={name} " not in log_text
682
+ and not log_text.endswith(f"[property-data] tool={name}")
683
+ and f"[property-data] tool={name}\n" not in log_text
684
+ ]
685
+
686
+
687
+ def _assert_observability_logs(log_path: Path, *, deadline_s: float = 60.0,
688
+ poll_s: float = 2.0, _now=time.monotonic,
689
+ _sleep=time.sleep) -> None:
690
+ """Poll the log until every PropertyData fan-out tool emitted its line.
691
+
692
+ Aborts with cause `observability-missing tool=<name>` once deadline_s
693
+ elapses if any expected name is still missing. `_now` and `_sleep` are
694
+ injectable for tests so the 60s budget can be exercised without
695
+ wall-clock waits.
696
+ """
697
+ deadline = _now() + deadline_s
698
+ while True:
699
+ try:
700
+ text = log_path.read_text()
701
+ except OSError:
702
+ text = ""
703
+ missing = _missing_property_data_tools(text)
704
+ if not missing:
705
+ return
706
+ if _now() >= deadline:
707
+ raise PrevalAbort(
708
+ f"observability-missing tool={missing[0]}",
709
+ f"PropertyData tool {missing[0]} did not emit "
710
+ f"`[property-data] tool={missing[0]} ...` within {int(deadline_s)}s "
711
+ "of [preval] start. The fan-out silently dropped one or more calls; "
712
+ "re-invoke and confirm every PropertyData tool returned before render. "
713
+ f"Other missing tool(s): {missing[1:] or 'none'}.",
714
+ )
715
+ _sleep(poll_s)
716
+
717
+
718
+ def _pick_log_path(out_dir: Path) -> Path:
719
+ """~/.maxy/logs/server.log if writable, else <out_dir>/preval.log.
720
+
721
+ Aborts with `log-destination-unavailable` if neither is writable —
722
+ silent runs are a contract violation.
723
+ """
724
+ primary = Path("~/.maxy/logs/server.log").expanduser()
725
+ for candidate in (primary, out_dir / "preval.log"):
726
+ try:
727
+ candidate.parent.mkdir(parents=True, exist_ok=True)
728
+ with candidate.open("a"):
729
+ pass
730
+ return candidate
731
+ except OSError:
732
+ continue
733
+ raise PrevalAbort(
734
+ "log-destination-unavailable",
735
+ "Neither ~/.maxy/logs/server.log nor <out_dir>/preval.log is writable. "
736
+ "Fix filesystem permissions before re-running.",
737
+ )
738
+
739
+
740
+ def _log_line(log_path: Path | None, line: str) -> None:
741
+ if log_path is not None:
742
+ try:
743
+ with log_path.open("a") as f:
744
+ f.write(line + "\n")
745
+ except OSError:
746
+ pass
747
+ print(line, file=sys.stderr)
748
+
749
+
750
+ def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float,
751
+ out_dir: Path | None, log_path: Path | None) -> None:
752
+ ms = int((time.monotonic() - t0) * 1000)
753
+ _log_line(
754
+ log_path,
755
+ f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
756
+ )
757
+ print(hint, file=sys.stderr)
758
+ if out_dir is not None:
759
+ p = out_dir / "index.html"
760
+ if p.exists():
761
+ p.unlink()
762
+ sys.exit(2)
763
+
764
+
457
765
  def main():
766
+ t0 = time.monotonic()
458
767
  if len(sys.argv) < 2:
459
768
  print("Usage: render.py <inputs.json>", file=sys.stderr)
460
769
  sys.exit(2)
461
770
  inputs = json.loads(Path(sys.argv[1]).read_text())
462
771
  out_dir = Path(inputs["out_dir"]).expanduser()
772
+ out_dir.mkdir(parents=True, exist_ok=True)
463
773
  base = inputs["filename_stem"]
464
- render(inputs, out_dir)
465
- pdf = render_pdf(out_dir, f"{base}.pdf")
466
- pages = assert_page_count(pdf, 5)
774
+ address = inputs.get("address", "")
775
+ postcode = inputs.get("postcode", "")
776
+
777
+ try:
778
+ log_path = _pick_log_path(out_dir)
779
+ except PrevalAbort as e:
780
+ _abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
781
+ out_dir=out_dir, log_path=None)
782
+
783
+ _log_line(log_path, f'[preval] start address="{address}" postcode={postcode}')
784
+
785
+ try:
786
+ _assert_observability_logs(log_path)
787
+ except PrevalAbort as e:
788
+ _abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
789
+ out_dir=out_dir, log_path=log_path)
790
+
791
+ try:
792
+ render(inputs, out_dir)
793
+ pdf = render_pdf(out_dir, f"{base}.pdf")
794
+ pages = assert_page_count(pdf, 5)
795
+ except PrevalAbort as e:
796
+ pdf_path = out_dir / f"{base}.pdf"
797
+ if pdf_path.exists():
798
+ pdf_path.unlink()
799
+ _abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
800
+ out_dir=out_dir, log_path=log_path)
801
+ except RuntimeError as e:
802
+ pdf_path = out_dir / f"{base}.pdf"
803
+ if pdf_path.exists():
804
+ pdf_path.unlink()
805
+ _abort("render-error", str(e), address=address, postcode=postcode, t0=t0,
806
+ out_dir=out_dir, log_path=log_path)
807
+
808
+ ms = int((time.monotonic() - t0) * 1000)
809
+ _log_line(log_path, f"[preval] done pdf={pdf} pages={pages} ms={ms}")
467
810
  print(f"HTML → {out_dir/'index.html'}")
468
811
  print(f"PDF → {pdf} ({pages} pages)")
469
812
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "property-preval inputs",
4
- "description": "Single-source contract for render.py. Mirrors the 12 property-data MCP responses + 1 EPC MCP response for one full UK address, plus operator-supplied brand, subject, market_summary, and agent_listings.",
4
+ "description": "Single-source contract for render.py. Mirrors the 12 property-data MCP responses for one full UK address, plus operator-supplied brand, subject (including the three EPC certificate fields pasted before invocation), market_summary, and agent_listings.",
5
5
  "type": "object",
6
6
  "required": [
7
7
  "out_dir", "filename_stem", "address", "postcode", "generated",
8
8
  "brand", "subject", "valuation", "area", "demand_trend",
9
- "market_summary", "agent_listings"
9
+ "market_summary", "agent_listings", "agent_overrides"
10
10
  ],
11
11
  "properties": {
12
12
  "out_dir": { "type": "string", "description": "Absolute path to the output directory (created if missing)." },
@@ -34,19 +34,17 @@
34
34
 
35
35
  "subject": {
36
36
  "type": "object",
37
- "required": ["cover_hero"],
37
+ "required": ["cover_hero", "total_floor_area_m2", "current_energy_rating", "lodgement_date"],
38
38
  "properties": {
39
- "cover_hero": { "type": "string", "description": "Filesystem path or URL to a photo of the subject property — required for the cover." },
40
- "beds": { "type": ["integer", "null"] },
41
- "type": { "type": ["string", "null"], "description": "e.g. 'Semi-detached', 'Terraced', 'Flat'." },
42
- "tenure": { "type": ["string", "null"], "description": "e.g. 'Freehold', 'Leasehold'." },
43
- "condition": { "type": ["string", "null"], "description": "'well' | 'average' | 'needs work'." },
44
- "sqft": { "type": ["integer", "null"], "description": "Total internal area in square feet. Pulled from EPC (m² × 10.7639, rounded) when available; operator paste-in otherwise. If null, valuation panel switches to 'requires floor area' mode." },
45
- "sqft_m2": { "type": ["number", "null"], "description": "Total internal area in square metres from the EPC register." },
46
- "epc_current": { "type": ["string", "null"], "description": "Current energy rating, e.g. 'C'." },
47
- "epc_potential": { "type": ["string", "null"], "description": "Potential energy rating, e.g. 'B'." },
48
- "epc_lodgement": { "type": ["string", "null"], "description": "ISO date of the EPC lodgement." },
49
- "narrative": { "type": ["string", "null"], "description": "One short paragraph the operator writes about the subject — shown under the valuation panel." },
39
+ "cover_hero": { "type": "string", "description": "Filesystem path or URL to a photo of the subject property — required for the cover. Renderer asserts the file exists, is ≥ 1200×800, and is not symlinked to another property's folder." },
40
+ "beds": { "type": ["integer", "null"] },
41
+ "type": { "type": ["string", "null"], "description": "e.g. 'Semi-detached', 'Terraced', 'Flat'." },
42
+ "tenure": { "type": ["string", "null"], "description": "e.g. 'Freehold', 'Leasehold'." },
43
+ "condition": { "type": ["string", "null"], "description": "'well' | 'average' | 'needs work'." },
44
+ "total_floor_area_m2": { "type": "number", "description": "Total internal area in square metres, copied verbatim from the EPC certificate. Operator pastes this into inputs.json before invocation. Null aborts with epc-certificate-not-supplied." },
45
+ "current_energy_rating": { "type": "string", "description": "Current energy rating from the EPC certificate, e.g. 'C'. Operator paste-in. Null aborts with epc-certificate-not-supplied." },
46
+ "lodgement_date": { "type": "string", "description": "ISO date of the EPC certificate's lodgement, e.g. '2018-04-21'. Operator paste-in. Null aborts with epc-certificate-not-supplied." },
47
+ "narrative": { "type": ["string", "null"], "description": "One short paragraph the operator writes about the subject — shown under the valuation panel." },
50
48
  "adjustments": {
51
49
  "type": "array",
52
50
  "description": "Operator-curated esoteric factors that shift the headline range. Deltas sum linearly into the final factor (1 + Σ delta_pct/100).",
@@ -155,23 +153,25 @@
155
153
 
156
154
  "market_summary": {
157
155
  "type": "object",
158
- "required": ["headline", "paragraphs", "demand_gauge_position", "price_growth_note", "demand_note"],
156
+ "required": ["headline", "paragraphs", "demand_gauge_position", "price_growth_note", "demand_note", "origin"],
159
157
  "properties": {
160
158
  "headline": { "type": "string", "description": "One-line display-italic h2 for the market context page." },
161
- "paragraphs": { "type": "array", "items": { "type": "string" }, "description": "2–3 short paragraphs. First gets a drop cap." },
159
+ "paragraphs": { "type": "array", "minItems": 2, "items": { "type": "string" }, "description": "2–3 short paragraphs. First gets a drop cap. Fewer than 2 aborts the run." },
162
160
  "demand_gauge_position": { "type": "string", "description": "Percentage string for the gauge marker position, e.g. '62%'." },
163
161
  "price_growth_note": { "type": "string" },
164
- "demand_note": { "type": "string" }
162
+ "demand_note": { "type": "string" },
163
+ "origin": { "type": "string", "enum": ["operator", "operator-edited-llm-stub"], "description": "Provenance of the paragraphs. 'agent-invented' is never permitted — the gate aborts with `market-summary-invented`." }
165
164
  }
166
165
  },
167
166
 
168
167
  "agent_listings": {
169
168
  "type": "array",
169
+ "minItems": 1,
170
170
  "maxItems": 6,
171
- "description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample).",
171
+ "description": "Agent's own recent listings to feature on the closing page. Operator paste-in (not the PropertyData sample). Empty array aborts the run.",
172
172
  "items": {
173
173
  "type": "object",
174
- "required": ["address", "price", "status", "slug"],
174
+ "required": ["address", "price", "status", "slug", "origin"],
175
175
  "properties": {
176
176
  "url": { "type": ["string", "null"], "description": "Source URL — listing card becomes an <a> when present." },
177
177
  "address": { "type": "string" },
@@ -179,10 +179,21 @@
179
179
  "beds": { "type": ["integer", "null"] },
180
180
  "type": { "type": ["string", "null"] },
181
181
  "image": { "type": ["string", "null"], "description": "Path or URL to a tile photo for the card." },
182
- "status": { "type": "string", "description": "'Sold' | 'Under offer' | 'For sale'." },
183
- "slug": { "type": "string", "description": "Used to name the copied image file under img/." }
182
+ "status": { "type": "string", "enum": ["Sold", "Under offer", "For sale"], "description": "Operator picks; no default. The gate aborts if status was set when the source property.json had status:null without agent_overrides.listings_status_curated." },
183
+ "slug": { "type": "string", "description": "Used to name the copied image file under img/." },
184
+ "origin": { "type": "string", "description": "'operator' when the operator typed the row by hand, or the filesystem path of the source property.json the row was copied from." }
184
185
  }
185
186
  }
187
+ },
188
+
189
+ "agent_overrides": {
190
+ "type": "object",
191
+ "description": "Explicit operator confirmations that authorise bypass of adherence gates. Each override is a named boolean that lands next to the disputed value in inputs.json — auditable post-hoc.",
192
+ "required": ["brand_selected_by_operator", "listings_status_curated"],
193
+ "properties": {
194
+ "brand_selected_by_operator": { "type": "boolean", "description": "True only when the operator told the agent which estate agent is pitching. Inferring brand from on-disk DESIGN.md files is invention." },
195
+ "listings_status_curated": { "type": "boolean", "description": "True only when the operator explicitly signed off on each agent_listings[].status value." }
196
+ }
186
197
  }
187
198
  }
188
199
  }
@@ -105,7 +105,6 @@
105
105
  .val-final{margin-top:5mm;padding:5mm 6mm;background:var(--teal-900);color:var(--text-on-dark);border-radius:var(--radius-lg);display:flex;justify-content:space-between;align-items:baseline;gap:4mm}
106
106
  .val-final .lbl{font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--gold-500)}
107
107
  .val-final .num{font-family:var(--display);font-style:italic;font-weight:500;font-size:26pt;color:var(--text-on-dark);letter-spacing:-.4pt}
108
- .val-empty{padding:8mm;text-align:center;font-family:var(--display);font-style:italic;font-size:14pt;color:var(--text-muted)}
109
108
 
110
109
  /* --- TABLES --- */
111
110
  table.cmp{width:100%;border-collapse:collapse;font-size:10pt;font-family:var(--body)}