@rubytech/create-maxy-code 0.1.60 → 0.1.61

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 +23 -17
  70. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -38
  71. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +288 -81
  72. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
  73. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +184 -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 +23 -17
  150. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -38
  151. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +288 -81
  152. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
  153. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +184 -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,37 @@ 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. Falls back to "Estimate requires floor area" benchmark-only mode when sqft is missing.
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
+ - **PropertyData key-not-registered** abort with the `property-data-key-register` prompt. Market data is essential; the pack cannot render without it.
36
+ - **EPC unavailable** — any of `key-not-registered`, `http-404 (no record)`, `http-429`, `http-500`, network failure, or EPC MCP not configured — is treated as a single class. The skill prompts the operator with the specific cause and offers two paths:
37
+ 1. Paste `subject.sqft` directly → pack renders with EPC KPI cells showing "—" + cause sub-label, but the valuation maths still runs on the pasted sqft.
38
+ 2. Decline → valuation panel switches to "Estimate requires floor area" benchmark-only mode; the pack still renders all five pages.
39
+ - **PropertyData http-429** → back off and retry only the throttled tool (4 calls per 10 seconds upstream throttle).
35
40
 
36
- ## Dependencies
41
+ ## External dependencies
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
+ - Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`).
44
+ - Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`) failure modes above; not strictly blocking thanks to the paste-sqft fallback.
45
+ - 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,118 @@
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`. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), operator pastes `subject.sqft` directly. Without a floor area the renderer prints "Estimate requires floor area" and shows only the £/sqft benchmark — never invents a number.
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. If `sqft` missing the panel switches to the "requires floor area" mode (£/sqft benchmark only). One paragraph of `subject.narrative` under the 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. Failure handling differs by source:
79
+ - **PropertyData `key-not-registered`** → abort with the `property-data-key-register` prompt. The pack cannot render without market data.
80
+ - **PropertyData `http-429`** back off, retry only the throttled tool. Do not re-run the whole fan-out.
81
+ - **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-500`, network failure, or MCP not configured on the account — is treated as a single class: **prompt the operator** with the relevant cause (e.g. "EPC API key not registered — register with `epc-key-register`, or paste subject.sqft to continue"; "No EPC on file for this address — paste subject.sqft to continue"; "EPC API unavailable — paste subject.sqft to continue"). If the operator pastes `subject.sqft`, the pack renders normally with the EPC-derived KPI cells switching to "—" + "No EPC on file" / "EPC API unavailable" sub-labels. If the operator declines to paste, the valuation panel switches to the "Estimate requires floor area" benchmark-only mode and the pack still renders the other four pages.
82
+ 3. Compute valuation range:
83
+ - `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
84
+ - `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
85
+ - `low = sqft × sold_psf_low`, `mid = sqft × sold_psf`, `high = sqft × sold_psf_high`
86
+ - Apply `adjustments` linearly: `factor = 1 + Σ delta_pct / 100`. Final range = `[low × factor, high × factor]`, midpoint = `mid × factor`.
87
+ 4. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
88
+ 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.
89
+ 6. Return the PDF path.
59
90
 
60
- ## Observability
91
+ ## Adjustments — linear vs compound
61
92
 
62
- Emit at start: `[preval] start address="<addr>" postcode=<pc>`.
63
- Emit at done: `[preval] done pdf=<path> pages=<n> ms=<n>`.
93
+ 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.
94
+
95
+ ## Observability
64
96
 
65
- Each of the 12 fan-out calls produces its own `[property-data] tool=<name>` line in `server.log` (inherited contract — no new instrumentation).
97
+ - `[preval] start address="<addr>" postcode=<pc>` at run start.
98
+ - `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
99
+ - 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).
66
100
 
67
101
  ## Failure signatures
68
102
 
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.
103
+ - `pages=6` (or ≠ 5) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>` to see which page bled, then trim that panel's padding only.
70
104
  - `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`).
105
+ - `[epc] status=http-404` property has no EPC; prompt "no EPC on file for this address paste subject.sqft to continue".
106
+ - `[epc] … status=key-not-registered` → prompt "EPC API key not registered — run `epc-key-register`, or paste subject.sqft to continue".
107
+ - `[epc] … status=http-429` | `http-5xx` | `bad-response` | `network-error` → prompt "EPC API unavailable — paste subject.sqft to continue". The MCP-not-installed case (no `[epc] …` line at all and the tool call surfaces as "tool not found") is the same prompt with cause "EPC MCP not configured on this account".
108
+ - `[property-data] … status=http-429` → 4-calls-in-10-seconds throttle. Retry only the throttled tool after the back-off; do not re-run the whole fan-out.
72
109
 
73
110
  ## Out of scope
74
111
 
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.
112
+ - Multi-address batch preval (CSV in → many PDFs out).
113
+ - Loop CRM auto-publish of the PDF onto a contact's record.
114
+ - Pulling `agent_listings` from Loop CRM automatically operator paste-in for now; CRM wire-up is a separate task.
115
+ - Caching of MCP responses (13 credits per pack is acceptable).
116
+ - A separate cover letter / pitch letter — the cover page *is* the pitch.
117
+ - Compound adjustments (linear sum is the contract; see above).
118
+ - 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.