@rubytech/create-maxy-code 0.1.62 → 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 (146) 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 +16 -8
  99. package/payload/platform/plugins/preval/hooks/preval-prompt-required.sh +140 -0
  100. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +44 -14
  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 +247 -62
  103. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +29 -19
  104. package/payload/platform/plugins/property-data/PLUGIN.md +22 -0
  105. package/payload/platform/plugins/replicate/PLUGIN.md +3 -0
  106. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -0
  107. package/payload/platform/plugins/tasks/PLUGIN.md +14 -0
  108. package/payload/platform/plugins/telegram/PLUGIN.md +3 -0
  109. package/payload/platform/plugins/waitlist/PLUGIN.md +7 -0
  110. package/payload/platform/plugins/whatsapp/PLUGIN.md +12 -0
  111. package/payload/platform/plugins/workflows/PLUGIN.md +8 -0
  112. package/payload/platform/services/claude-session-manager/dist/index.js +39 -1
  113. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  114. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -0
  115. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  116. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +148 -41
  117. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  118. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +23 -0
  119. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -0
  120. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +141 -0
  121. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -0
  122. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +13 -2
  123. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
  124. package/payload/platform/services/claude-session-manager/dist/tool-surface.js +135 -37
  125. package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
  126. package/payload/premium-plugins/real-agent/agents/compliance.md +1 -1
  127. package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +5 -0
  128. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +27 -0
  129. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
  130. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +16 -8
  131. package/payload/premium-plugins/real-agent/plugins/preval/hooks/preval-prompt-required.sh +140 -0
  132. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +44 -14
  133. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/__tests__/adherence.test.py +373 -0
  134. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +247 -62
  135. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +29 -19
  136. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +22 -0
  137. package/payload/server/public/assets/{admin-DhN3G8W7.js → admin-ymReS1mN.js} +1 -1
  138. package/payload/server/public/assets/{data-B2ZVXOcE.js → data-D6U2e85B.js} +1 -1
  139. package/payload/server/public/assets/{graph-BAMGPHrK.js → graph-C274PN27.js} +1 -1
  140. package/payload/server/public/assets/{graph-labels-D9eBbvxo.js → graph-labels-DQFzd1FY.js} +1 -1
  141. package/payload/server/public/assets/{page-DjdVMWCz.js → page-D-OLjort.js} +1 -1
  142. package/payload/server/public/assets/{page-CV27Al6Z.js → page-DFFt9_Q0.js} +1 -1
  143. package/payload/server/public/data.html +3 -3
  144. package/payload/server/public/graph.html +3 -3
  145. package/payload/server/public/index.html +4 -4
  146. package/payload/server/server.js +1 -1
@@ -5,7 +5,9 @@ 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
7
  Fails loud on any missing / unverifiable load-bearing input. No silent
8
- fallback modes — see _assert_inputs.
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.
9
11
 
10
12
  Usage:
11
13
  python3 render.py inputs.json
@@ -217,9 +219,11 @@ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
217
219
  def _valuation_block(subject: dict, valuation: dict) -> str:
218
220
  """Build the page-2 valuation panel: headline range, adjustments, final range.
219
221
 
220
- sqft is asserted non-null upstream by _assert_inputs — no fallback panel here.
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.
221
225
  """
222
- sqft = subject["sqft"]
226
+ sqft = _derived_sqft(subject)
223
227
  low_psf = valuation["sold_psf_low"]
224
228
  mid_psf = valuation["sold_psf"]
225
229
  high_psf = valuation["sold_psf_high"]
@@ -268,25 +272,20 @@ def _valuation_block(subject: dict, valuation: dict) -> str:
268
272
  return '<div class="val-panel">' + "".join(rows) + "</div>" + final
269
273
 
270
274
 
275
+ def _derived_sqft(subject: dict) -> int:
276
+ return round(float(subject["total_floor_area_m2"]) * M2_TO_SQFT)
277
+
278
+
271
279
  def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
272
- sqft = subject["sqft"]
273
- m2 = subject.get("sqft_m2")
274
- sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
275
- return f"{int(sqft):,}", sub
280
+ sqft = _derived_sqft(subject)
281
+ m2 = float(subject["total_floor_area_m2"])
282
+ return f"{sqft:,}", f"≈ {m2:.0f} m² · from EPC certificate"
276
283
 
277
284
 
278
285
  def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
279
- current = subject.get("epc_current")
280
- potential = subject.get("epc_potential")
281
- lodgement = subject.get("epc_lodgement")
282
- if not current:
283
- return "—", "No EPC on file"
284
- sub_bits = []
285
- if potential:
286
- sub_bits.append(f"potential {potential}")
287
- if lodgement:
288
- sub_bits.append(f"lodged {lodgement}")
289
- 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}"
290
289
 
291
290
 
292
291
  # ---------------- input assertions ----------------
@@ -303,6 +302,30 @@ class PrevalAbort(RuntimeError):
303
302
  self.hint = hint
304
303
 
305
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
+
306
329
  def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
307
330
  """Existence + dimension check. Symlinks pointing into another property's
308
331
  folder are rejected (cover-hero cross-contamination guard)."""
@@ -316,12 +339,10 @@ def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> No
316
339
  target = p.resolve()
317
340
  parts = target.parts
318
341
  if "properties" in parts:
319
- # symlink → properties/<other-slug>/… is a cross-contamination
320
342
  raise PrevalAbort(
321
343
  "subject-cover-hero-symlinked-foreign",
322
344
  f"cover_hero is a symlink into another property's folder: {target}",
323
345
  )
324
- # Dimensions. Avoid a Pillow dep; read PNG/JPEG headers directly.
325
346
  w, h = _image_dimensions(p)
326
347
  if w is None or h is None:
327
348
  raise PrevalAbort(
@@ -343,7 +364,6 @@ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
343
364
  if len(head) < 24:
344
365
  return None, None
345
366
  if head[:8] == b"\x89PNG\r\n\x1a\n":
346
- # IHDR width/height at bytes 16..24, big-endian uint32.
347
367
  w = int.from_bytes(head[16:20], "big")
348
368
  h = int.from_bytes(head[20:24], "big")
349
369
  return w, h
@@ -357,7 +377,7 @@ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
357
377
  if not marker:
358
378
  return None, None
359
379
  if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
360
- f.read(3) # length(2) + precision(1)
380
+ f.read(3)
361
381
  h = int.from_bytes(f.read(2), "big")
362
382
  w = int.from_bytes(f.read(2), "big")
363
383
  return w, h
@@ -371,23 +391,36 @@ def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
371
391
  def _assert_inputs(inputs: dict) -> None:
372
392
  """Fail loud on any missing or unverifiable load-bearing input.
373
393
 
374
- Raises PrevalAbort with a named cause. No silent fallback paths.
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.
375
405
  """
376
406
  subject = inputs.get("subject") or {}
407
+ address = inputs.get("address") or ""
408
+ overrides = inputs.get("agent_overrides") or {}
377
409
 
378
- # subject.sqft / sqft_m2 required, non-null integers.
379
- if not subject.get("sqft"):
380
- raise PrevalAbort(
381
- "subject-sqft-missing",
382
- "subject.sqft is null — run epc-key-register and re-derive, or paste subject.sqft as an operator override before invocation.",
383
- )
384
- if not subject.get("sqft_m2"):
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:
385
416
  raise PrevalAbort(
386
- "subject-sqft-m2-missing",
387
- "subject.sqft_m2 is null — the EPC figure must be supplied alongside sqft.",
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.",
388
422
  )
389
423
 
390
- # subject.cover_hero — required, exists, ≥ 1200×800, not symlinked into another property's folder.
391
424
  hero = subject.get("cover_hero")
392
425
  if not hero:
393
426
  raise PrevalAbort(
@@ -397,7 +430,6 @@ def _assert_inputs(inputs: dict) -> None:
397
430
  if not hero.startswith(("http://", "https://", "data:")):
398
431
  _assert_path_image(hero)
399
432
 
400
- # brand — must be the resolved DESIGN.md object, not a stub.
401
433
  brand = inputs.get("brand") or {}
402
434
  required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
403
435
  missing_brand = [k for k in required_brand if not brand.get(k)]
@@ -407,21 +439,73 @@ def _assert_inputs(inputs: dict) -> None:
407
439
  f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
408
440
  )
409
441
 
410
- # agent_listings — non-empty.
411
442
  if not inputs.get("agent_listings"):
412
443
  raise PrevalAbort(
413
444
  "agent-listings-empty",
414
445
  "agent_listings is empty — paste at least one recent listing for the closing page.",
415
446
  )
416
447
 
417
- # market_summary.paragraphs at least 2.
418
- paras = (inputs.get("market_summary") or {}).get("paragraphs") or []
448
+ ms = inputs.get("market_summary") or {}
449
+ paras = ms.get("paragraphs") or []
419
450
  if len(paras) < 2:
420
451
  raise PrevalAbort(
421
452
  "market-summary-too-thin",
422
453
  f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
423
454
  )
424
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
+ )
508
+
425
509
 
426
510
  # ---------------- render ----------------
427
511
 
@@ -452,10 +536,9 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
452
536
 
453
537
  psf = d.get("sales_per_month")
454
538
  if psf is None:
455
- psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
539
+ psf = max(1, round(d["for_sale"] / 7))
456
540
 
457
541
  slots = {
458
- # branding
459
542
  "brand_name": brand["name"],
460
543
  "brand_tagline": brand["tagline"],
461
544
  "brand_primary": brand["primary"],
@@ -467,14 +550,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
467
550
  "wordmark_light": _wordmark(brand, "light", out_dir),
468
551
  "wordmark_dark": _wordmark(brand, "dark", out_dir),
469
552
 
470
- # cover + header
471
553
  "subject_hero_path": _hero_asset(subject, out_dir),
472
554
  "address": inputs["address"],
473
555
  "address_short": _short_address(inputs["address"]),
474
556
  "postcode": inputs["postcode"],
475
557
  "generated": inputs["generated"],
476
558
 
477
- # page 2 — subject valuation
478
559
  "kpi_subject_sqft": subject_sqft,
479
560
  "kpi_subject_sqft_sub": subject_sqft_sub,
480
561
  "kpi_subject_beds": str(subject.get("beds") or "—"),
@@ -485,7 +566,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
485
566
  "valuation_block": _valuation_block(subject, v),
486
567
  "subject_narrative": subject.get("narrative") or "",
487
568
 
488
- # page 3 — market context
489
569
  "summary_headline": ms["headline"],
490
570
  "summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
491
571
  "kpi_avg_asking": _money(v["avg_asking"]),
@@ -498,15 +578,12 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
498
578
  "demand_note": ms["demand_note"],
499
579
  "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
500
580
 
501
- # page 4 — comparables
502
581
  "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
503
582
  "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
504
583
 
505
- # page 5 — agent listings + sources
506
584
  "agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
507
585
  }
508
586
 
509
- # Escape only the plain-text slots; HTML fragments pass through untouched.
510
587
  text_slots = {
511
588
  "address", "address_short", "postcode", "generated",
512
589
  "brand_name", "brand_tagline",
@@ -553,11 +630,6 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
553
630
 
554
631
 
555
632
  def _count_pages(pdf_path: Path) -> int:
556
- """pdfinfo first (handles /ObjStm-compressed page-object streams), with a
557
- raw /Type /Page byte scan as fallback. The fallback is correct for current
558
- Chrome --print-to-pdf output but would undercount if Chrome ever switches
559
- to compressed page-object streams — hence pdfinfo first when present.
560
- """
561
633
  pdfinfo = shutil.which("pdfinfo")
562
634
  if pdfinfo:
563
635
  result = subprocess.run(
@@ -580,20 +652,113 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
580
652
  return n
581
653
 
582
654
 
583
- def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float, out_dir: Path | None) -> None:
584
- """Emit the abort log line, scrub any partial output, exit non-zero."""
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:
585
752
  ms = int((time.monotonic() - t0) * 1000)
586
- print(
753
+ _log_line(
754
+ log_path,
587
755
  f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
588
- file=sys.stderr,
589
756
  )
590
757
  print(hint, file=sys.stderr)
591
758
  if out_dir is not None:
592
- for name in ("index.html",):
593
- p = out_dir / name
594
- if p.exists():
595
- p.unlink()
596
- # Any PDF whose stem matches filename_stem would be a partial — caller passes the stem.
759
+ p = out_dir / "index.html"
760
+ if p.exists():
761
+ p.unlink()
597
762
  sys.exit(2)
598
763
 
599
764
 
@@ -604,24 +769,44 @@ def main():
604
769
  sys.exit(2)
605
770
  inputs = json.loads(Path(sys.argv[1]).read_text())
606
771
  out_dir = Path(inputs["out_dir"]).expanduser()
772
+ out_dir.mkdir(parents=True, exist_ok=True)
607
773
  base = inputs["filename_stem"]
608
774
  address = inputs.get("address", "")
609
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
+
610
791
  try:
611
792
  render(inputs, out_dir)
612
793
  pdf = render_pdf(out_dir, f"{base}.pdf")
613
794
  pages = assert_page_count(pdf, 5)
614
795
  except PrevalAbort as e:
615
- # Scrub partial PDF too, if rendered before page-count failure.
616
796
  pdf_path = out_dir / f"{base}.pdf"
617
797
  if pdf_path.exists():
618
798
  pdf_path.unlink()
619
- _abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0, out_dir=out_dir)
799
+ _abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0,
800
+ out_dir=out_dir, log_path=log_path)
620
801
  except RuntimeError as e:
621
802
  pdf_path = out_dir / f"{base}.pdf"
622
803
  if pdf_path.exists():
623
804
  pdf_path.unlink()
624
- _abort("render-error", str(e), address=address, postcode=postcode, t0=t0, out_dir=out_dir)
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}")
625
810
  print(f"HTML → {out_dir/'index.html'}")
626
811
  print(f"PDF → {pdf} ({pages} pages)")
627
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", "sqft", "sqft_m2"],
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. 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
- "sqft": { "type": "integer", "description": "Total internal area in square feet. Required. Pulled from EPC (m² × 10.7639, rounded) when available; operator paste-in is the only override. Null aborts the run." },
45
- "sqft_m2": { "type": "number", "description": "Total internal area in square metres from the EPC register. Required alongside sqft." },
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,13 +153,14 @@
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
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
 
@@ -172,7 +171,7 @@
172
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.",
173
172
  "items": {
174
173
  "type": "object",
175
- "required": ["address", "price", "status", "slug"],
174
+ "required": ["address", "price", "status", "slug", "origin"],
176
175
  "properties": {
177
176
  "url": { "type": ["string", "null"], "description": "Source URL — listing card becomes an <a> when present." },
178
177
  "address": { "type": "string" },
@@ -180,10 +179,21 @@
180
179
  "beds": { "type": ["integer", "null"] },
181
180
  "type": { "type": ["string", "null"] },
182
181
  "image": { "type": ["string", "null"], "description": "Path or URL to a tile photo for the card." },
183
- "status": { "type": "string", "description": "'Sold' | 'Under offer' | 'For sale'." },
184
- "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." }
185
185
  }
186
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
+ }
187
197
  }
188
198
  }
189
199
  }
@@ -4,48 +4,70 @@ description: "PropertyData UK market analytics — postcode-keyed valuation, ask
4
4
  tools:
5
5
  - name: property-data-key-register
6
6
  publicAllowlist: false
7
+ adminAllowlist: false
7
8
  - name: property-data-key-deregister
8
9
  publicAllowlist: false
10
+ adminAllowlist: false
9
11
  - name: property-data-key-list
10
12
  publicAllowlist: false
13
+ adminAllowlist: false
11
14
  - name: property-data-valuation-sale
12
15
  publicAllowlist: false
16
+ adminAllowlist: false
13
17
  - name: property-data-valuation-rent
14
18
  publicAllowlist: false
19
+ adminAllowlist: false
15
20
  - name: property-data-prices
16
21
  publicAllowlist: false
22
+ adminAllowlist: false
17
23
  - name: property-data-prices-rent
18
24
  publicAllowlist: false
25
+ adminAllowlist: false
19
26
  - name: property-data-sold-prices
20
27
  publicAllowlist: false
28
+ adminAllowlist: false
21
29
  - name: property-data-demand
22
30
  publicAllowlist: false
31
+ adminAllowlist: false
23
32
  - name: property-data-yields
24
33
  publicAllowlist: false
34
+ adminAllowlist: false
25
35
  - name: property-data-growth
26
36
  publicAllowlist: false
37
+ adminAllowlist: false
27
38
  - name: property-data-national-hpi
28
39
  publicAllowlist: false
40
+ adminAllowlist: false
29
41
  - name: property-data-prices-per-sqf
30
42
  publicAllowlist: false
43
+ adminAllowlist: false
31
44
  - name: property-data-sold-prices-per-sqf
32
45
  publicAllowlist: false
46
+ adminAllowlist: false
33
47
  - name: property-data-crime
34
48
  publicAllowlist: false
49
+ adminAllowlist: false
35
50
  - name: property-data-flood-risk
36
51
  publicAllowlist: false
52
+ adminAllowlist: false
37
53
  - name: property-data-council-tax
38
54
  publicAllowlist: false
55
+ adminAllowlist: false
39
56
  - name: property-data-planning-applications
40
57
  publicAllowlist: false
58
+ adminAllowlist: false
41
59
  - name: property-data-address-match-uprn
42
60
  publicAllowlist: false
61
+ adminAllowlist: false
43
62
  - name: property-data-growth-psf
44
63
  publicAllowlist: false
64
+ adminAllowlist: false
45
65
  - name: property-data-demand-rent
46
66
  publicAllowlist: false
67
+ adminAllowlist: false
47
68
  - name: property-data-property-types
48
69
  publicAllowlist: false
70
+ adminAllowlist: false
49
71
  skills:
50
72
  - skills/property-data/SKILL.md
51
73
  always: false