@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/platform/plugins/docs/references/admin-session.md +2 -2
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/platform/plugins/epc/PLUGIN.md +85 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/platform/plugins/epc/mcp/package.json +21 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +25 -17
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +107 -39
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +449 -84
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +183 -124
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/admin/SOUL.md +1 -1
- package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
- package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +25 -17
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +107 -39
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +449 -84
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +183 -124
- package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
- package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
- package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
- package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
- package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- 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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "real-agent-preval",
|
|
3
|
-
"description": "Single-address pre-valuation pack. One full UK address in,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
19
|
-
- **Page 2 —
|
|
20
|
-
- **Page 3 —
|
|
21
|
-
- **Page 4 —
|
|
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
|
-
##
|
|
24
|
+
## Tool dependencies
|
|
24
25
|
|
|
25
|
-
The skill
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
- `property-data-planning-applications` — planning is a separate dev-precedent report.
|
|
33
|
+
## Failure handling
|
|
33
34
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
10
|
+
## Typography is fixed; only colours and logos come from the agent
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Same typographic lock as `property-market-report`:
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
-
|
|
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
|
-
##
|
|
20
|
-
|
|
21
|
-
A directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
|
|
20
|
+
## Inputs (never invented)
|
|
22
21
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
33
|
+
## Tools the skill calls
|
|
29
34
|
|
|
30
|
-
|
|
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
|
|
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
|
|
49
|
+
12. `property-data-council-tax` — band D + per-property bands (full postcode only).
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
**EPC (1 call — free, rate-limited):**
|
|
46
52
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
2. Run the
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
132
|
+
## Failure diagnostics
|
|
68
133
|
|
|
69
|
-
- `pages=
|
|
70
|
-
- `start` with no matching `done` within 60s → renderer crashed. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
|
|
71
|
-
-
|
|
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)
|
|
76
|
-
- Loop CRM auto-publish of the PDF onto a contact's record
|
|
77
|
-
-
|
|
78
|
-
- Caching of MCP responses
|
|
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.
|