@rubytech/create-maxy-code 0.1.60 → 0.1.62

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 (163) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  4. package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  5. package/payload/platform/plugins/docs/references/admin-session.md +2 -2
  6. package/payload/platform/plugins/docs/references/internals.md +1 -1
  7. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  8. package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
  9. package/payload/platform/plugins/epc/PLUGIN.md +85 -0
  10. package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
  11. package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  12. package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
  13. package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
  14. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  15. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  16. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  17. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  18. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  19. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  20. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  21. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  22. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  23. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  24. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  25. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  26. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  27. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  28. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  29. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  30. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  31. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  32. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  33. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  34. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  35. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  36. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  37. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  38. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  39. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  40. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  41. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  42. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  43. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  44. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  45. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  46. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  47. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  48. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  49. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  50. package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
  51. package/payload/platform/plugins/epc/mcp/package.json +21 -0
  52. package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  53. package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  54. package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  55. package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  56. package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
  57. package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  58. package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  59. package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  60. package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  61. package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  62. package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  63. package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  64. package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  65. package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  66. package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
  67. package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
  68. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
  69. package/payload/platform/plugins/preval/PLUGIN.md +25 -17
  70. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +107 -39
  71. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +449 -84
  72. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
  73. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +183 -124
  74. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  75. package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
  76. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  78. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  79. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
  80. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  81. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  82. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  83. package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
  84. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  85. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  86. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  87. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  88. package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
  89. package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
  90. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
  91. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  92. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
  93. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
  94. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  95. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  96. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  97. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  98. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  99. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  100. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  101. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  102. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  103. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  104. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  105. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  106. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  107. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  108. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  109. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  110. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  111. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  112. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  113. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  114. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  115. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  116. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  117. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  118. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  119. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  120. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  121. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  122. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  123. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  124. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  125. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  126. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  127. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  128. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  129. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  130. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
  131. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
  132. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  133. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  134. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  135. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  136. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
  137. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  138. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  139. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  140. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  141. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  142. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  143. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  144. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  145. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  146. package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
  147. package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
  148. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
  149. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +25 -17
  150. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +107 -39
  151. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +449 -84
  152. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
  153. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +183 -124
  154. package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
  155. package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
  156. package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
  157. package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
  158. package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
  159. package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
  160. package/payload/server/public/data.html +3 -3
  161. package/payload/server/public/graph.html +3 -3
  162. package/payload/server/public/index.html +4 -4
  163. package/payload/server/server.js +3 -16
@@ -0,0 +1,172 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { getSession } from "./neo4j.js";
5
+ import { encrypt, decrypt } from "./crypto.js";
6
+ import { encryptWith, decryptWith } from "./file-crypto.js";
7
+
8
+ // Storage abstraction so the same MCP body runs against Neo4j (platform mode)
9
+ // or an on-disk encrypted file (standalone mode, e.g. spawned by Claude Code
10
+ // from ~/.claude/). Selected by the EPC_STANDALONE env flag at boot. Mirrors
11
+ // the property-data key-store with one node per account: EPC keys are
12
+ // account-scoped and the gov.uk EPC API has no team concept.
13
+
14
+ export interface StoredKey {
15
+ apiKey: string;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ }
19
+
20
+ export interface KeyStore {
21
+ readonly backendName: string;
22
+ readonly backendPath: string;
23
+ load(accountId: string): Promise<StoredKey | null>;
24
+ save(accountId: string, apiKey: string): Promise<void>;
25
+ delete(accountId: string): Promise<boolean>;
26
+ }
27
+
28
+ class Neo4jKeyStore implements KeyStore {
29
+ readonly backendName = "neo4j";
30
+ readonly backendPath = process.env.NEO4J_URI ?? "<unset>";
31
+
32
+ async load(accountId: string): Promise<StoredKey | null> {
33
+ const session = getSession();
34
+ try {
35
+ const result = await session.run(
36
+ `MATCH (k:EpcApiKey {accountId: $accountId})
37
+ RETURN k.encryptedKey AS encryptedKey,
38
+ k.createdAt AS createdAt,
39
+ k.updatedAt AS updatedAt`,
40
+ { accountId },
41
+ );
42
+ if (result.records.length === 0) return null;
43
+ const r = result.records[0];
44
+ return {
45
+ apiKey: decrypt(r.get("encryptedKey")),
46
+ createdAt: r.get("createdAt") ?? "",
47
+ updatedAt: r.get("updatedAt") ?? "",
48
+ };
49
+ } finally {
50
+ await session.close();
51
+ }
52
+ }
53
+
54
+ async save(accountId: string, apiKey: string): Promise<void> {
55
+ const encryptedKey = encrypt(apiKey);
56
+ const now = new Date().toISOString();
57
+ const session = getSession();
58
+ try {
59
+ await session.run(
60
+ `CREATE INDEX epc_api_key_account IF NOT EXISTS
61
+ FOR (k:EpcApiKey) ON (k.accountId)`,
62
+ {},
63
+ );
64
+ await session.run(
65
+ `MERGE (k:EpcApiKey {accountId: $accountId})
66
+ ON CREATE SET k.encryptedKey = $encryptedKey, k.createdAt = $now, k.updatedAt = $now
67
+ ON MATCH SET k.encryptedKey = $encryptedKey, k.updatedAt = $now`,
68
+ { accountId, encryptedKey, now },
69
+ );
70
+ } finally {
71
+ await session.close();
72
+ }
73
+ }
74
+
75
+ async delete(accountId: string): Promise<boolean> {
76
+ const session = getSession();
77
+ try {
78
+ const result = await session.run(
79
+ `MATCH (k:EpcApiKey {accountId: $accountId})
80
+ DELETE k
81
+ RETURN count(k) AS deleted`,
82
+ { accountId },
83
+ );
84
+ const deleted = result.records[0]?.get("deleted")?.toNumber?.() ?? 0;
85
+ return deleted > 0;
86
+ } finally {
87
+ await session.close();
88
+ }
89
+ }
90
+ }
91
+
92
+ interface FileEnvelope {
93
+ [accountId: string]: { encryptedKey: string; createdAt: string; updatedAt: string };
94
+ }
95
+
96
+ const LABEL = "epc";
97
+
98
+ class FileKeyStore implements KeyStore {
99
+ readonly backendName = "file";
100
+ readonly backendPath: string;
101
+ private readonly storePath: string;
102
+ private readonly keyFilePath: string;
103
+
104
+ constructor() {
105
+ const home = homedir();
106
+ this.storePath = resolve(home, ".claude/.realagent-epc-key.enc");
107
+ this.keyFilePath = resolve(home, ".claude/.realagent-epc-encryption-key");
108
+ this.backendPath = this.storePath;
109
+ }
110
+
111
+ private ensureDir(): void {
112
+ mkdirSync(dirname(this.storePath), { recursive: true });
113
+ }
114
+
115
+ private readAll(): FileEnvelope {
116
+ if (!existsSync(this.storePath)) return {};
117
+ const blob = readFileSync(this.storePath, "utf-8").trim();
118
+ if (!blob) return {};
119
+ const json = decryptWith(blob, this.keyFilePath, LABEL);
120
+ return JSON.parse(json) as FileEnvelope;
121
+ }
122
+
123
+ private writeAll(env: FileEnvelope): void {
124
+ this.ensureDir();
125
+ const blob = encryptWith(JSON.stringify(env), this.keyFilePath, LABEL);
126
+ writeFileSync(this.storePath, blob, { mode: 0o600 });
127
+ }
128
+
129
+ async load(accountId: string): Promise<StoredKey | null> {
130
+ const env = this.readAll();
131
+ const entry = env[accountId];
132
+ if (!entry) return null;
133
+ return {
134
+ apiKey: decryptWith(entry.encryptedKey, this.keyFilePath, LABEL),
135
+ createdAt: entry.createdAt,
136
+ updatedAt: entry.updatedAt,
137
+ };
138
+ }
139
+
140
+ async save(accountId: string, apiKey: string): Promise<void> {
141
+ const env = this.readAll();
142
+ const now = new Date().toISOString();
143
+ const encryptedKey = encryptWith(apiKey, this.keyFilePath, LABEL);
144
+ const prev = env[accountId];
145
+ env[accountId] = {
146
+ encryptedKey,
147
+ createdAt: prev?.createdAt ?? now,
148
+ updatedAt: now,
149
+ };
150
+ this.writeAll(env);
151
+ }
152
+
153
+ async delete(accountId: string): Promise<boolean> {
154
+ const env = this.readAll();
155
+ if (!(accountId in env)) return false;
156
+ delete env[accountId];
157
+ this.writeAll(env);
158
+ return true;
159
+ }
160
+ }
161
+
162
+ let cached: KeyStore | null = null;
163
+
164
+ export function selectKeyStore(): KeyStore {
165
+ if (cached) return cached;
166
+ cached = process.env.EPC_STANDALONE === "1" ? new FileKeyStore() : new Neo4jKeyStore();
167
+ return cached;
168
+ }
169
+
170
+ export function _resetKeyStoreForTests(): void {
171
+ cached = null;
172
+ }
@@ -0,0 +1,47 @@
1
+ import neo4j, { Driver, Session } from "neo4j-driver";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ let driver: Driver | null = null;
6
+
7
+ function readPassword(): string {
8
+ if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
9
+ const passwordFile = resolve(
10
+ process.env.PLATFORM_ROOT ?? resolve(import.meta.dirname, "../../../.."),
11
+ "config/.neo4j-password",
12
+ );
13
+ try {
14
+ return readFileSync(passwordFile, "utf-8").trim();
15
+ } catch {
16
+ throw new Error(
17
+ `Neo4j password not found. Expected at ${passwordFile} or in NEO4J_PASSWORD env var.`,
18
+ );
19
+ }
20
+ }
21
+
22
+ export function getDriver(): Driver {
23
+ if (!driver) {
24
+ const uri = process.env.NEO4J_URI;
25
+ if (!uri) {
26
+ throw new Error(
27
+ "[epc] NEO4J_URI unset — refusing to default to bolt://localhost:7687",
28
+ );
29
+ }
30
+ const user = process.env.NEO4J_USER ?? "neo4j";
31
+ const password = readPassword();
32
+ console.error(`[epc] resolved neo4j_uri=${uri}`);
33
+ driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
34
+ }
35
+ return driver;
36
+ }
37
+
38
+ export function getSession(): Session {
39
+ return getDriver().session();
40
+ }
41
+
42
+ export async function closeDriver(): Promise<void> {
43
+ if (driver) {
44
+ await driver.close();
45
+ driver = null;
46
+ }
47
+ }
@@ -0,0 +1,9 @@
1
+ import { selectKeyStore } from "../lib/key-store.js";
2
+
3
+ export async function keyDeregister(params: { accountId: string }): Promise<void> {
4
+ const deleted = await selectKeyStore().delete(params.accountId);
5
+ if (!deleted) {
6
+ throw new Error("No EPC API key is registered for this account.");
7
+ }
8
+ console.error(`[epc] key-op op=deregister account=${params.accountId}`);
9
+ }
@@ -0,0 +1,10 @@
1
+ import { loadAccountKey } from "../lib/epc-api.js";
2
+
3
+ export async function keyList(params: { accountId: string }): Promise<string> {
4
+ const key = await loadAccountKey(params.accountId);
5
+ console.error(`[epc] key-op op=list account=${params.accountId}`);
6
+ if (!key) {
7
+ return "No EPC API key registered. Use epc-key-register to add one.";
8
+ }
9
+ return `EPC API key registered (created: ${key.createdAt}, updated: ${key.updatedAt}). Key value is encrypted and cannot be retrieved.`;
10
+ }
@@ -0,0 +1,44 @@
1
+ import { selectKeyStore } from "../lib/key-store.js";
2
+ import { BASE_URL, REQUEST_TIMEOUT_MS, EpcError } from "../lib/epc-api.js";
3
+
4
+ // Probe the EPC register with the supplied credential against
5
+ // /domestic/search constrained to a tiny page so we do not pull a real
6
+ // payload. A valid credential returns HTTP 200 (rows may be empty for a
7
+ // nonsense postcode, which is fine — authentication is what we test).
8
+ // An invalid credential returns HTTP 401 Unauthorized.
9
+
10
+ async function validateApiKey(credential: string): Promise<void> {
11
+ const url = `${BASE_URL}/domestic/search?postcode=SW1A1AA&size=1`;
12
+ const response = await fetch(url, {
13
+ headers: {
14
+ Accept: "application/json",
15
+ Authorization: `Basic ${Buffer.from(credential, "utf-8").toString("base64")}`,
16
+ },
17
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
18
+ });
19
+ if (response.status === 401 || response.status === 403) {
20
+ throw new Error(
21
+ `EPC register rejected the credential (HTTP ${response.status}). Expected format is email:api-key.`,
22
+ );
23
+ }
24
+ // 200, 404 (no rows for that postcode), and 200-with-rows all prove the
25
+ // credential was accepted. Anything else surfaces as the upstream code.
26
+ if (response.status === 200 || response.status === 404) return;
27
+ const body = await response.text().catch(() => "");
28
+ throw new EpcError(response.status, "domestic/search", body.slice(0, 200));
29
+ }
30
+
31
+ export async function keyRegister(params: {
32
+ apiKey: string;
33
+ accountId: string;
34
+ }): Promise<void> {
35
+ const { apiKey, accountId } = params;
36
+ if (!apiKey.includes(":")) {
37
+ throw new Error(
38
+ "EPC credential must be in the form email:api-key (email of the account that generated the key, then a colon, then the API key).",
39
+ );
40
+ }
41
+ await validateApiKey(apiKey);
42
+ await selectKeyStore().save(accountId, apiKey);
43
+ console.error(`[epc] key-op op=register account=${accountId}`);
44
+ }
@@ -0,0 +1,43 @@
1
+ import { epcSearch, groupAndPickMostRecent, EpcMatch } from "../lib/epc-api.js";
2
+
3
+ interface LookupResult {
4
+ status: "ok";
5
+ matches: EpcMatch[];
6
+ }
7
+
8
+ export async function epcLookupByAddress(args: {
9
+ accountId: string;
10
+ address: string;
11
+ postcode?: string;
12
+ }): Promise<string> {
13
+ const { accountId, address, postcode } = args;
14
+ const { rows } = await epcSearch({
15
+ toolName: "epc-lookup-by-address",
16
+ accountId,
17
+ identifier: address,
18
+ params: { address, postcode, size: 25 },
19
+ });
20
+ const result: LookupResult = {
21
+ status: "ok",
22
+ matches: groupAndPickMostRecent(rows),
23
+ };
24
+ return JSON.stringify(result, null, 2);
25
+ }
26
+
27
+ export async function epcLookupByUprn(args: {
28
+ accountId: string;
29
+ uprn: string;
30
+ }): Promise<string> {
31
+ const { accountId, uprn } = args;
32
+ const { rows } = await epcSearch({
33
+ toolName: "epc-lookup-by-uprn",
34
+ accountId,
35
+ identifier: uprn,
36
+ params: { uprn, size: 25 },
37
+ });
38
+ const result: LookupResult = {
39
+ status: "ok",
40
+ matches: groupAndPickMostRecent(rows),
41
+ };
42
+ return JSON.stringify(result, null, 2);
43
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src"],
19
+ "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
20
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/__tests__/**/*.test.ts"],
6
+ environment: "node",
7
+ },
8
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "real-agent-preval",
3
- "description": "Single-address pre-valuation pack. One full UK address in, 4-page A4 PDF out (cover · valuation · area · demand+trend+sources). Consumes the real-agent-property-data MCP — no MCP tools of its own.",
3
+ "description": "Single-address pre-valuation pitch pack. One full UK address in, 5-page A4 PDF out (cover · subject valuation · market context · comparables · recently with <brand>). Consumes the real-agent-property-data and real-agent-epc MCPs — no MCP tools of its own.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Rubytech LLC"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: real-agent-preval
3
- description: "Single-address pre-valuation pack. One full UK address in, 4-page A4 PDF out (cover · valuation · area · demand+trend+sources). Consumes the real-agent-property-data MCP — no MCP tools of its own."
3
+ description: "Single-address pre-valuation pitch pack. One full UK address in, 5-page A4 PDF out (cover · subject valuation · market context · comparables · recently with <brand>). Consumes the real-agent-property-data and real-agent-epc MCPs — no MCP tools of its own."
4
4
  skills:
5
5
  - skills/property-preval/SKILL.md
6
6
  always: false
@@ -9,31 +9,39 @@ metadata: {"platform":{"optional":true,"embed":["admin"]}}
9
9
 
10
10
  # Real Agent — Preval
11
11
 
12
- Consumer sub-plugin. No MCP server, no new tools — only a skill (`property-preval`) that takes one full UK address and renders a 4-page A4 pre-valuation PDF by fanning out to 12 of the 22 `real-agent-property-data` MCP tools.
12
+ Consumer sub-plugin. No MCP server, no new tools — only a skill (`property-preval`) that takes one full UK address plus operator-supplied brand tokens, subject details, agent listings, and a market summary, and renders a **5-page A4 pre-valuation PDF** designed as a sales pitch for the listing.
13
13
 
14
14
  ## What it produces
15
15
 
16
- A 4-page A4 PDF and the source HTML, written to `~/Documents/Maxy/preval/<postcode>-<YYYY-MM-DD>/`:
16
+ A 5-page A4 PDF and the source HTML, written to `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/`:
17
17
 
18
- - **Page 1 — Cover.** Full address, postcode chip, generated-date meta strip.
19
- - **Page 2 — Valuation.** Average asking £, asking £/sqft, sold avg £, sold £/sqft. Nearest-sold comps table. Live asking comps grid.
20
- - **Page 3 — Area.** Crime rating, flood risk, council-tax band D cost, local area population. Crime breakdown panel + council-tax band table. Headline read.
21
- - **Page 4 — Demand, trend, sources.** Demand rating, days-on-market, turnover, for-sale count. 7y growth sparkline (£ and £/sqft). Rental-side mini-panel. Property-types doughnut. Sources/methodology block.
18
+ - **Page 1 — Cover.** Full-bleed subject hero photo with gradient scrim. Light brand wordmark top-left. Address in display-italic. Postcode chip. Four-up meta strip (Report · Address · Postcode · Generated). Lifted from the `property-market-report` cover contract.
19
+ - **Page 2 — Subject valuation.** KPI strip (floor area, bedrooms, EPC rating, type). Valuation panel: headline range = `subject.sqft × sold £/sqft 70pc band` (Land Registry), asking £/sqft cross-check, operator adjustments (`subject.adjustments[]`) shown as a stacked list with colour-coded percentages, final indicative range pill. `subject.sqft` is required — a null value aborts the run.
20
+ - **Page 3 — Market context.** Market summary headline + paragraphs with drop cap. KPI strip (avg asking £, avg sold £, £/sqft). Price-growth panel (YoY) + demand pill with gauge. 7-year sparkline of £ and £/sqft. Same patterns as `property-market-report`.
21
+ - **Page 4 — Comparables.** Full-width Land Registry sold-transactions table (≤ 6 rows), stacked above a full-width live-asking-comparables tile grid ( 4 tiles). Never side-by-side corrects the cramped two-column layout in v1 preval.
22
+ - **Page 5 — Recently with `<brand>` + sources.** Operator-curated `agent_listings[]` rendered as a clickable tile grid with status badges (Sold / Under offer / For sale), then the sources & methodology block. The vendor reads this page and knows who to call.
22
23
 
23
- ## How the address routes
24
+ ## Tool dependencies
24
25
 
25
- The skill derives the postcode from the address (UK-postcode regex on the final token group), then fans out to **12** `real-agent-property-data` MCP tools in fixed call order:
26
+ The skill fans out to **13** MCP calls in parallel:
26
27
 
27
- `prices`, `prices-per-sqf`, `sold-prices`, `sold-prices-per-sqf`, `growth`, `growth-psf`, `demand`, `demand-rent`, `property-types`, `crime`, `flood-risk`, `council-tax`.
28
+ - **12 PropertyData** tools (postcode-keyed): `prices`, `prices-per-sqf`, `sold-prices`, `sold-prices-per-sqf`, `growth`, `growth-psf`, `demand`, `demand-rent`, `property-types`, `crime`, `flood-risk`, `council-tax`.
29
+ - **1 EPC** tool (address-keyed): `epc-lookup-by-address` for subject floor area + energy rating + lodgement date.
28
30
 
29
- Two tools from the same MCP are **deliberately not called** by this skill:
31
+ `property-data-address-match-uprn` and `property-data-planning-applications` are deliberately not called (CRM hygiene and dev-precedent reports, respectively).
30
32
 
31
- - `property-data-address-match-uprn` — UPRN is CRM hygiene, not preval data.
32
- - `property-data-planning-applications` — planning is a separate dev-precedent report.
33
+ ## Failure handling
33
34
 
34
- Operators can still reach those tools via the underlying `property-data` skill or directly through the MCP.
35
+ Every failure is fail-loud. The skill emits `[preval] aborted reason=<cause> …` and exits 2; no partial `index.html` or PDF is left on disk.
35
36
 
36
- ## Dependencies
37
+ - **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
38
+ - **PropertyData `http-429`** → back off and retry the single throttled tool once. A second `http-429` aborts `reason=property-data-rate-limited`.
39
+ - **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
40
+ - **EPC failure of any kind** — `key-not-registered`, `http-404 (no record)`, `http-429`, `http-5xx`, network failure, EPC MCP not configured — aborts the run. There is no in-band prompt and no benchmark-only mode. The operator's options before re-running are (a) run Task 155 to deploy / register the EPC MCP, or (b) paste `subject.sqft` and `subject.sqft_m2` into the inputs as a deliberate operator override.
41
+ - See `skills/property-preval/SKILL.md` § Hard breakpoints for the full table of abort causes.
37
42
 
38
- - Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`). Without a key every fan-out call returns `Error: key-not-registered` and the renderer raises before producing a PDF.
39
- - Requires Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` (mac, headless `--print-to-pdf`). Same Chrome dependency as `property-market-report`.
43
+ ## External dependencies
44
+
45
+ - Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`).
46
+ - Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`), **or** an operator-supplied `subject.sqft` + `subject.sqft_m2` override pre-baked into the inputs. EPC failures are not silently absorbed.
47
+ - Requires Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` for headless `--print-to-pdf`. Same Chrome dependency as `property-market-report`.
@@ -1,78 +1,146 @@
1
1
  ---
2
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>".
3
+ description: Use when an operator wants a pre-valuation pack to pitch for a UK property listing a 5-page A4 PDF that combines a market-data report for the postcode with the subject property's own valuation range, full-width sold + asking comparables, and the agent's recent successes as the sales close. Triggers include "preval <address>", "pre-valuation for <address>", "valuation pitch for <address>", "what's <address> worth and pitch us to list it", "pre-appraisal pack for <address>".
4
4
  ---
5
5
 
6
6
  # Property Preval
7
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.
8
+ A 5-page A4 PDF that pitches the agent for the listing: cover (full-bleed subject hero), subject valuation (sqft × £/sqft band with operator adjustments), market context, full-width comparables (sold + asking stacked), and the agent's recent successes + disclaimer. **Preval is a sales pitch, not just a data sheet** — every page either tells the vendor what the property is worth or what the agent has recently delivered nearby.
9
9
 
10
- ## Input
10
+ ## Typography is fixed; only colours and logos come from the agent
11
11
 
12
- A single string the full UK address, ending in a valid UK postcode. Examples:
12
+ Same typographic lock as `property-market-report`:
13
13
 
14
- - `47 Phoenix Drive, Bishop's Stortford, CM23 2UJ`
15
- - `Flat 3, 122 Kensington High Street, London, W8 4SG`
14
+ - **Display** Cormorant Garamond (italic for headlines, KPI values, prices, addresses)
15
+ - **Body** Lora
16
+ - **Sans / labels** — Inter (eyebrows, KPI labels, chips, footer meta)
16
17
 
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
+ Never substitute the agent's body or display fonts even if their `DESIGN.md` specifies different families. The pack reads as an editorial / luxury document across every brand. The agent's identity comes through the **palette and logo only**.
18
19
 
19
- ## Output
20
-
21
- A directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
20
+ ## Inputs (never invented)
22
21
 
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.
22
+ - **`address`** — full UK address ending in a valid postcode (e.g. `9 Phoenix Drive, Bishop's Stortford, CM23 2UJ`). Skill derives the postcode by matching the UK postcode regex on the final token group; stops if no match.
23
+ - **`brand`**agent design tokens, same shape as property-market-report: `name`, `tagline`, hex tokens `primary` / `primary_dark` / `accent` / `paper` / `paper_banded` / `rule`. Optional `logo_light` / `logo_dark` filesystem paths. Resolved from the agent's `DESIGN.md` (e.g. `/estate-agents/muvin/DESIGN.md`); read the canonical hex values directly, never substitute "close" colours. Only colour tokens are consumed; typography/spacing/radius tokens in the agent's `DESIGN.md` are ignored by design.
24
+ - **`subject`** — paste-in details about the property being valued:
25
+ - `cover_hero` (filesystem path or URL to a photo of the subject — required for the cover).
26
+ - Optional `beds`, `type`, `tenure`, `condition` (well / average / needs work).
27
+ - `adjustments[]` — operator-curated array of `{label, delta_pct}` covering esoteric factors that shift the headline range (e.g. `{"label":"South-facing garden","delta_pct":3}`, `{"label":"Needs full reno","delta_pct":-8}`). Deltas sum linearly into the final range.
28
+ - `narrative` — one short paragraph the operator writes about the property.
29
+ - **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. Both are required. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), the operator pastes `subject.sqft` directly **before invocation** as a deliberate override. There is no mid-run paste prompt and no benchmark-only render — a null `sqft` aborts the run with `reason=subject-sqft-missing`.
30
+ - **`agent_listings`** — the agent's recent sold / under-offer / live listings to feature on the closing page. Up to 6 entries of `{url, address, price, beds, type, image, status, slug}` where `status ∈ {"Sold","Under offer","For sale"}`. Operator picks the comps that make the strongest pitch; this is *not* the PropertyData sample.
31
+ - **`market_summary`** — `headline` (one display-italic line), `paragraphs[]` (2–3 short paragraphs), `demand_gauge_position` (percentage string like `"62%"`), `price_growth_note`, `demand_note`. The operator drafts or accepts an LLM stub.
27
32
 
28
- ## Tools the skill calls (in order)
33
+ ## Tools the skill calls
29
34
 
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):
35
+ Parallel fan-out keyed on the derived postcode + the subject address:
31
36
 
37
+ **PropertyData (12 calls — credit cost 12):**
32
38
  1. `property-data-prices` — asking £ distribution + raw asking comps.
33
39
  2. `property-data-prices-per-sqf` — asking £/sqft.
34
40
  3. `property-data-sold-prices` — Land Registry sold transactions + raw sold comps.
35
- 4. `property-data-sold-prices-per-sqf` — achieved £/sqft.
41
+ 4. `property-data-sold-prices-per-sqf` — achieved £/sqft (plus 70pc band used for the valuation range).
36
42
  5. `property-data-growth` — 7y price series.
37
43
  6. `property-data-growth-psf` — 7y £/sqft series.
38
44
  7. `property-data-demand` — for-sale, sales/mo, turnover, MoI, DoM, demand rating.
39
45
  8. `property-data-demand-rent` — rental-side demand pillar.
40
- 9. `property-data-property-types` — mix doughnut (detached / semi / terraced / flat).
46
+ 9. `property-data-property-types` — mix (detached / semi / terraced / flat).
41
47
  10. `property-data-crime` — area crime rating + breakdown.
42
48
  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).
49
+ 12. `property-data-council-tax` — band D + per-property bands (full postcode only).
44
50
 
45
- ## Tools the skill deliberately does NOT call
51
+ **EPC (1 call free, rate-limited):**
46
52
 
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.
53
+ 13. `epc-lookup-by-address(address, postcode)` — subject floor area + energy ratings + lodgement date.
49
54
 
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.
55
+ `address-match-uprn` and `planning-applications` are deliberately omitted (CRM hygiene and dev-precedent reports respectively).
56
+
57
+ ## Output
58
+
59
+ Directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
60
+
61
+ - `index.html` — single self-contained file.
62
+ - `img/` — copied logos and the subject hero image.
63
+ - `<slug>-<date>.pdf` — exactly **5** A4 pages. Renderer asserts via `pdfinfo` and raises if ≠ 5.
64
+
65
+ Return the PDF path to the operator.
66
+
67
+ ## Pages
68
+
69
+ 1. **Cover** — full-bleed `subject.cover_hero` with vertical gradient scrim; light logo top-left; eyebrow "`<brand.name>` · Pre-valuation"; address in display-italic; postcode chip; four-up meta strip (Report · Address · Postcode · Generated). Lifted from property-market-report's cover contract.
70
+ 2. **Subject valuation** — KPI strip (sqft, beds, EPC rating, type). Centre panel: headline range = `sqft × sold_psf_low–high` (Land Registry 70pc band) with `sqft × asking_psf` shown as a cross-check. Operator `adjustments[]` displayed as a stacked list (label · delta% · running £). Final range pill at the foot. One paragraph of `subject.narrative` under the panel. `sqft` is required — a null value aborts the run, never renders a benchmark-only panel.
71
+ 3. **Market context** — KPI cube (avg asking, avg sold, £/sqft, YoY); pill + gauge for demand; 7y growth sparkline (£ solid + £/sqft dashed); summary paragraphs. Same patterns as property-market-report's page 2.
72
+ 4. **Comparables (full-width, stacked)** — Nearest sold table (top 6) full-width across the page; Live asking tile grid (top 4) full-width below. Vertically stacked, never side-by-side — corrects the cramped two-column layout in the v1 preval where the asking tiles got pushed to ~40mm columns of italic addresses.
73
+ 5. **Recently with `<brand.name>` + sources** — agent's own listings as a clickable tile grid (`<a>` per card, status badge: Sold / Under offer / For sale), then the sources / methodology / accuracy / investment block. Closes with `Real Agent · Generated <date> · <postcode>` in the footer.
51
74
 
52
75
  ## How to execute
53
76
 
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.
77
+ 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 if no match.
78
+ 2. Run the 13 fan-out tools (12 PropertyData + 1 EPC) in parallel. Every failure is fail-loud no mid-run paste prompts, no benchmark-only render, no degraded pack on disk:
79
+ - **PropertyData `key-not-registered`** → abort `reason=property-data-key-not-registered` with the `property-data-key-register` remediation hint.
80
+ - **PropertyData `http-429`** back off and retry the single throttled tool **once**. A second `http-429` aborts `reason=property-data-rate-limited`.
81
+ - **PropertyData `http-5xx` / network failure** → abort `reason=property-data-unavailable`.
82
+ - **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-5xx`, network failure, or MCP not configured on the account — aborts the run. The operator's options before re-running are (a) run Task 155 to deploy / register the EPC MCP, or (b) paste `subject.sqft` (plus `subject.sqft_m2`) into the inputs as a deliberate operator override. There is no in-band prompt and no benchmark-only mode.
83
+ 3. Compute valuation range:
84
+ - `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
85
+ - `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
86
+ - `low = sqft × sold_psf_low`, `mid = sqft × sold_psf`, `high = sqft × sold_psf_high`
87
+ - Apply `adjustments` linearly: `factor = 1 + Σ delta_pct / 100`. Final range = `[low × factor, high × factor]`, midpoint = `mid × factor`.
88
+ 4. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
89
+ 5. `python3 references/render.py inputs.json`. Renderer fills the template, copies logos + hero into `img/`, drives Chrome `--print-to-pdf`, asserts page count == 5.
90
+ 6. Return the PDF path.
91
+
92
+ ## Adjustments — linear vs compound
93
+
94
+ Deltas sum linearly (`-8 + 3 = -5%`, applied as `× 0.95`). Compound interpretation (`× 0.92 × 1.03`) would give a slightly different number (-5.24%) but the operator presents the figures verbally as a sum, so the displayed maths must match the verbal claim. If a future operator wants compound adjustments, this is a deliberate skill-level rewrite, not a config switch.
59
95
 
60
96
  ## Observability
61
97
 
62
- Emit at start: `[preval] start address="<addr>" postcode=<pc>`.
63
- Emit at done: `[preval] done pdf=<path> pages=<n> ms=<n>`.
98
+ - `[preval] start address="<addr>" postcode=<pc>` at run start.
99
+ - `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
100
+ - `[preval] aborted reason=<cause> address="<addr>" postcode=<pc> ms=<n>` on any abort (one line, then exit 2; no `done`).
101
+ - Each fan-out call emits its own `[property-data] tool=<name>` or `[epc] tool=<name>` line in `server.log` (inherited contracts; no new instrumentation here).
102
+
103
+ ## Hard breakpoints
104
+
105
+ Each row aborts the run with the named cause. No PDF or `index.html` is left on disk. Operator sees a single-line cause + one-line remediation hint, nothing else.
106
+
107
+ | Forbidden state | `reason=` cause |
108
+ |---|---|
109
+ | EPC MCP not configured / `key-not-registered` / `http-5xx` / network error | `epc-mcp-unavailable` (operator must run Task 155 or paste `subject.sqft` before invocation) |
110
+ | `subject.sqft` null after EPC lookup | `subject-sqft-missing` |
111
+ | `subject.sqft_m2` null | `subject-sqft-m2-missing` |
112
+ | `subject.cover_hero` missing, unreachable, below 1200×800, or symlinked into another property's folder | `subject-cover-hero-missing` / `subject-cover-hero-unreadable` / `subject-cover-hero-too-small` / `subject-cover-hero-symlinked-foreign` |
113
+ | `brand` not resolved from an on-disk `DESIGN.md` (required token missing) | `brand-unresolved` |
114
+ | `agent_listings` empty | `agent-listings-empty` |
115
+ | `market_summary.paragraphs` < 2 entries | `market-summary-too-thin` |
116
+ | PropertyData any-call `http-429` after 1 retry | `property-data-rate-limited` |
117
+ | PropertyData any-call `key-not-registered` | `property-data-key-not-registered` (operator runs `property-data-key-register`) |
118
+ | EPC lookup returns `http-404` (no record on file) | `epc-no-record` (operator pastes `subject.sqft` as override and re-runs, or accepts that this address can't be preval'd until lodged) |
119
+
120
+ ## Forbidden behaviours
121
+
122
+ The skill is the contract; if any of these slip through, the pack must not render:
64
123
 
65
- Each of the 12 fan-out calls produces its own `[property-data] tool=<name>` line in `server.log` (inherited contract no new instrumentation).
124
+ - Silent fallback selection switching to a benchmark-only / "Estimate requires floor area" panel when an input is missing.
125
+ - Inventing inputs — fabricating `brand`, `subject.cover_hero`, `market_summary.paragraphs`, `agent_listings`, or any field the operator did not supply.
126
+ - Reusing a hero photo from a different property as a placeholder.
127
+ - Defaulting `agent_listings[].status` to `"For sale"` when the source is null.
128
+ - Mid-run prompts for a paste-in (EPC retries, sqft, etc). Overrides are operator-supplied **before** invocation.
129
+ - Omitting the `[preval]` start / done / aborted log emissions.
130
+ - Leaving partial output (`index.html`, `<stem>.pdf`) on disk after an abort.
66
131
 
67
- ## Failure signatures
132
+ ## Failure diagnostics
68
133
 
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`).
134
+ - `pages=6` (or ≠ 5) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>`, then trim that panel's padding only.
135
+ - `start` with no matching `done` or `aborted` within 60s → renderer crashed mid-run. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
136
+ - `[preval] aborted reason=<cause>` look up `<cause>` in the **Hard breakpoints** table for the remediation hint.
72
137
 
73
138
  ## Out of scope
74
139
 
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.
140
+ - Multi-address batch preval (CSV in → many PDFs out).
141
+ - Loop CRM auto-publish of the PDF onto a contact's record.
142
+ - Pulling `agent_listings` from Loop CRM automatically operator paste-in for now; CRM wire-up is a separate task.
143
+ - Caching of MCP responses (13 credits per pack is acceptable).
144
+ - A separate cover letter / pitch letter — the cover page *is* the pitch.
145
+ - Compound adjustments (linear sum is the contract; see above).
146
+ - Subject sqft from sources other than EPC and the operator paste-in fallback (e.g. Loop CRM floor plans, Rightmove sqft scrape) — separate task if pursued.