@rubytech/create-realagent-code 0.1.144 → 0.1.147

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 (137) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/schema.cypher +154 -4
  3. package/payload/platform/plugins/admin/PLUGIN.md +1 -2
  4. package/payload/platform/plugins/admin/hooks/__tests__/session-end-retrospective.test.sh +2 -2
  5. package/payload/platform/plugins/admin/hooks/post-turn-graph-pass.sh +251 -0
  6. package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +6 -3
  7. package/payload/platform/plugins/admin/skills/datetime/SKILL.md +63 -5
  8. package/payload/platform/plugins/docs/references/internals.md +16 -0
  9. package/payload/platform/plugins/docs/references/memory-guide.md +10 -0
  10. package/payload/platform/plugins/docs/references/platform.md +1 -1
  11. package/payload/platform/plugins/memory/.claude-plugin/plugin.json +1 -1
  12. package/payload/platform/plugins/memory/PLUGIN.md +25 -3
  13. package/payload/platform/plugins/memory/mcp/dist/index.js +309 -11
  14. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.d.ts +2 -0
  16. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.d.ts.map +1 -0
  17. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js +33 -0
  18. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-label.test.d.ts +2 -0
  20. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-label.test.d.ts.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-label.test.js +30 -0
  22. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-label.test.js.map +1 -0
  23. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/phase-prune-revisions.test.d.ts +2 -0
  24. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/phase-prune-revisions.test.d.ts.map +1 -0
  25. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/phase-prune-revisions.test.js +51 -0
  26. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/phase-prune-revisions.test.js.map +1 -0
  27. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/relative-date.test.d.ts +2 -0
  28. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/relative-date.test.d.ts.map +1 -0
  29. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/relative-date.test.js +68 -0
  30. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/relative-date.test.js.map +1 -0
  31. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js +41 -0
  32. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js.map +1 -1
  33. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-revision.d.ts +43 -0
  34. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-revision.d.ts.map +1 -0
  35. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-revision.js +33 -0
  36. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-revision.js.map +1 -0
  37. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts +18 -5
  38. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts.map +1 -1
  39. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js +64 -17
  40. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js.map +1 -1
  41. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/index.d.ts +44 -0
  42. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/index.d.ts.map +1 -0
  43. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/index.js +14 -0
  44. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/index.js.map +1 -0
  45. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-citation-audit.d.ts +26 -0
  46. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-citation-audit.d.ts.map +1 -0
  47. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-citation-audit.js +219 -0
  48. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-citation-audit.js.map +1 -0
  49. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-dead-edges.d.ts +19 -0
  50. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-dead-edges.d.ts.map +1 -0
  51. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-dead-edges.js +39 -0
  52. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-dead-edges.js.map +1 -0
  53. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-orphans.d.ts +19 -0
  54. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-orphans.d.ts.map +1 -0
  55. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-orphans.js +42 -0
  56. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-orphans.js.map +1 -0
  57. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-prune-revisions.d.ts +21 -0
  58. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-prune-revisions.d.ts.map +1 -0
  59. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-prune-revisions.js +27 -0
  60. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-prune-revisions.js.map +1 -0
  61. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-stale-truth.d.ts +18 -0
  62. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-stale-truth.d.ts.map +1 -0
  63. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-stale-truth.js +60 -0
  64. package/payload/platform/plugins/memory/mcp/dist/lib/dream-cycle/phase-stale-truth.js.map +1 -0
  65. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts +20 -3
  66. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts.map +1 -1
  67. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js +47 -12
  68. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js.map +1 -1
  69. package/payload/platform/plugins/memory/mcp/dist/lib/relative-date.d.ts +13 -0
  70. package/payload/platform/plugins/memory/mcp/dist/lib/relative-date.d.ts.map +1 -0
  71. package/payload/platform/plugins/memory/mcp/dist/lib/relative-date.js +191 -0
  72. package/payload/platform/plugins/memory/mcp/dist/lib/relative-date.js.map +1 -0
  73. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts +7 -1
  74. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts.map +1 -1
  75. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js +37 -9
  76. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js.map +1 -1
  77. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-compiled-truth-history.test.d.ts +2 -0
  78. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-compiled-truth-history.test.d.ts.map +1 -0
  79. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-compiled-truth-history.test.js +81 -0
  80. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-compiled-truth-history.test.js.map +1 -0
  81. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-report-tools.test.d.ts +2 -0
  82. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-report-tools.test.d.ts.map +1 -0
  83. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-report-tools.test.js +115 -0
  84. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-report-tools.test.js.map +1 -0
  85. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +1 -0
  86. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
  87. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +1 -0
  88. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
  89. package/payload/platform/plugins/memory/mcp/dist/tools/memory-compiled-truth-history.d.ts +19 -0
  90. package/payload/platform/plugins/memory/mcp/dist/tools/memory-compiled-truth-history.d.ts.map +1 -0
  91. package/payload/platform/plugins/memory/mcp/dist/tools/memory-compiled-truth-history.js +50 -0
  92. package/payload/platform/plugins/memory/mcp/dist/tools/memory-compiled-truth-history.js.map +1 -0
  93. package/payload/platform/plugins/memory/mcp/dist/tools/memory-dream-run.d.ts +45 -0
  94. package/payload/platform/plugins/memory/mcp/dist/tools/memory-dream-run.d.ts.map +1 -0
  95. package/payload/platform/plugins/memory/mcp/dist/tools/memory-dream-run.js +170 -0
  96. package/payload/platform/plugins/memory/mcp/dist/tools/memory-dream-run.js.map +1 -0
  97. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-list.d.ts +28 -0
  98. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-list.d.ts.map +1 -0
  99. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-list.js +64 -0
  100. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-list.js.map +1 -0
  101. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-read-latest.d.ts +24 -0
  102. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-read-latest.d.ts.map +1 -0
  103. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-read-latest.js +49 -0
  104. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-read-latest.js.map +1 -0
  105. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-write.d.ts +34 -0
  106. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-write.d.ts.map +1 -0
  107. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-write.js +99 -0
  108. package/payload/platform/plugins/memory/mcp/dist/tools/memory-report-write.js.map +1 -0
  109. package/payload/platform/plugins/memory/mcp/dist/tools/memory-review-queue.d.ts +29 -0
  110. package/payload/platform/plugins/memory/mcp/dist/tools/memory-review-queue.d.ts.map +1 -0
  111. package/payload/platform/plugins/memory/mcp/dist/tools/memory-review-queue.js +66 -0
  112. package/payload/platform/plugins/memory/mcp/dist/tools/memory-review-queue.js.map +1 -0
  113. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  114. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +122 -2
  115. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  116. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +49 -13
  117. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  118. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +184 -16
  119. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  120. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  121. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +53 -10
  122. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  123. package/payload/platform/plugins/memory/mcp/vitest.config.ts +6 -0
  124. package/payload/platform/plugins/memory/references/schema-base.md +4 -0
  125. package/payload/platform/plugins/memory/skills/conversation-archive/SKILL.md +3 -4
  126. package/payload/platform/plugins/scheduling/skills/briefing/SKILL.md +13 -2
  127. package/payload/platform/scripts/seed-neo4j.sh +1 -1
  128. package/payload/platform/templates/agents/admin/IDENTITY.md +9 -1
  129. package/payload/platform/templates/specialists/agents/database-operator.md +30 -2
  130. package/payload/premium-plugins/real-agent/agents/listing-curator.md +39 -10
  131. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +1 -0
  132. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/build.md +10 -1
  133. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/images.md +104 -0
  134. package/payload/server/server.js +195 -91
  135. package/payload/platform/plugins/admin/hooks/signal-detector-stop.sh +0 -309
  136. package/payload/platform/plugins/admin/skills/commitment-followthrough/SKILL.md +0 -60
  137. package/payload/platform/templates/specialists/agents/signal-detector.md +0 -129
@@ -36,6 +36,14 @@ When the conversation produces something worth writing to memory (a new person,
36
36
 
37
37
  Call `memory-write` or `memory-update` directly only when the write is a one-line update against a node whose `elementId` is already in your context (e.g. setting a single property on an existing Person you just found by id) AND the schema choice is unambiguous. Delegate in every other case: new nodes, batched writes, multi-row archive ingest, schema-classification work, anything whose label or edge type requires consulting `schema-base.md` or a vertical schema.
38
38
 
39
+ ## Recalling reports
40
+
41
+ Workflow output that the operator may want back later (daily briefing, dream cycle, ad-hoc analyses) lives as `:Report` nodes — keyword-routed, append-only, embedded for hybrid search. When the operator says "latest <X>", "last night's <X>", "show me <X> report", "what was this morning's briefing", or any other phrasing that names a known report kind, the first tool call is `memory-report-read-latest` with the matching keyword (`daily-briefing`, `dream-cycle`, etc.). Use `memory-report-list` to browse the catalogue (metadata only) when the operator wants a date range or to compare multiple. Reach for `memory-search` instead only when the question is about the contents of a report (e.g. "which briefing mentioned the Cohen offer"), not about the report itself.
42
+
43
+ ## Compiled-truth revision history
44
+
45
+ Every `compiledTruth` on a :Person, :Organization, or :Concept is captured as a `:CompiledTruthRevision` row each time the rewriter writes it. When the operator asks "how has my understanding of X changed over time?", "when did you start saying X about Y?", or "show me the history of what we know about Z", first resolve the entity's `elementId` (via `memory-search`), then call `memory-compiled-truth-history` with that id. Render the rows as a chronological list and surface the delta between adjacent revisions in your own reply — there is no server-side diff.
46
+
39
47
  ## Session-end retrospective
40
48
 
41
49
  When the operator signals end-of-session (`/end`, `/archive`, `end session`, or `archive this session`), the Stop hook blocks this session from closing until you have done three things and called one tool. The hook's instruction block names the three passes verbatim every time it fires; this section is the doctrine behind it.
@@ -63,7 +71,7 @@ When you receive a turn beginning with `[system: <name>-stop-hook]`:
63
71
  2. Execute that single `Task` dispatch. Do not paraphrase the prompt, do not respond conversationally, do not ask the operator for clarification, do not surface the dispatch in chat.
64
72
  3. When the subagent returns its one-line summary, emit a single space character and stop. The Stop hook's next fire will inspect the transcript, see the dispatch you just performed, and allow the stop.
65
73
 
66
- These directives are never operator-driven. The `signal-detector` specialist in particular (gbrain port, Task 303) is dispatched only by `signal-detector-stop.sh` and never by operator intent.
74
+ These directives are never operator-driven. The `database-operator` specialist in particular, when dispatched via `post-turn-graph-pass.sh` (Task 412), runs as a background graph pass that the operator does not see; the dispatch prompt carries the conversation and prior-writes blocks and you must forward both verbatim.
67
75
 
68
76
  ## SOUL.md
69
77
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: database-operator
3
- description: "Graph-write executor reachable from admin via the Task tool. Admin names what should land in the graph; database-operator reads the schema, decides operations, executes them against the memory graph, and reports `{label, elementId}` back. (Reachable historically also from the now-dormant Stop hook in `platform/plugins/admin/hooks/turn-completed-graph-write.sh`; that path is preserved as infrastructure but no longer registered, see Task 214.)"
4
- summary: "Executes graph writes on admin's behalf when delegated via the Task tool."
3
+ description: "Graph-write executor reachable from admin via the Task tool. Admin names what should land in the graph; database-operator reads the schema, decides operations, executes them against the memory graph, and reports `{label, elementId}` back. Two invocation modes: (1) **discretionary inline** admin dispatches synchronously when a write must complete before the assistant response ends; (2) **background post-turn pass** fired once per admin-agent end_turn by `platform/plugins/admin/hooks/post-turn-graph-pass.sh` with the full conversation and the list of nodes already written under this conversationId, so any new graph rows the conversation supports get derived and written without the admin agent having to remember every turn (Task 412)."
4
+ summary: "Executes graph writes on admin's behalf when delegated via the Task tool — both inline at admin discretion and as a once-per-turn background pass."
5
5
  model: claude-sonnet-4-6
6
6
  tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-search, mcp__plugin_memory_memory__memory-archive-write, mcp__plugin_memory_memory__memory-typed-edge-pass, mcp__plugin_memory_memory__session-retrospective-skip-rate, mcp__plugin_memory_memory__profile-update, mcp__plugin_memory_memory__profile-read, mcp__plugin_memory_memory__obsidian-vault-import, mcp__plugin_scheduling_scheduling__schedule-archive-ics, mcp__plugin_contacts_contacts__contact-create, mcp__plugin_contacts_contacts__contact-update, mcp__plugin_work_work__work-create, mcp__plugin_work_work__work-update, mcp__plugin_work_work__project-create, mcp__plugin_work_work__project-update
7
7
  ---
@@ -20,6 +20,34 @@ The `accountId` property is supplied by the writers from server-side environment
20
20
 
21
21
  Decide the precise operations against the schema, execute them with the writers in your tool surface, and report each write back to admin as `{label, elementId}` so admin can confirm the outcome to the operator. If admin's brief is ambiguous about which label, which edge type, or which existing node to attach to, name the ambiguity in your reply rather than guessing.
22
22
 
23
+ ## Background post-turn pass
24
+
25
+ When the dispatch prompt contains a `<conversation>` block and a `<prior-writes>` block, you are running as the post-turn background pass (Task 412). The hook (`platform/plugins/admin/hooks/post-turn-graph-pass.sh`) fires once per admin-agent `end_turn` and supplies:
26
+
27
+ - `accountId`, `sessionId`, `conversationId` — the scope.
28
+ - `<conversation>` — every assistant text turn and every non-`tool_result` user turn for this session, oldest first, formatted as `[role: text]` pairs.
29
+ - `<prior-writes>` — every node already written under this `conversationId` for this account, one per line as `(elementId) :Labels {properties-json}`, oldest first.
30
+
31
+ Your job: read the conversation, decide what new graph rows it supports (mentions of named entities, ideas and learnings the operator stated, contacts named for the first time, tasks the operator committed to, decisions, anything the schema admits), and write them. **Skip writes the `<prior-writes>` list already covers** — a `:MENTIONS` edge from a `:Message` to a `:Person` is already there, do not re-emit it; a `:Person {name: "Adam Langley"}` is already there, attach further edges to its `elementId` instead of creating a duplicate. The hygiene sweeps (Tasks 410, 411) clean up duplicates that slip through MERGE semantics; do not invent dedup logic here beyond the prior-writes skip.
32
+
33
+ The same prerogatives apply: precise label and edge names, evidence-based lookups (`memory-search` before creating any stub for a name that could already exist on another conversation), no synonyms, no invented edge types.
34
+
35
+ Return exactly one line on completion:
36
+
37
+ ```
38
+ db-op: writes=<n> ms=<n>
39
+ ```
40
+
41
+ Where `<n>` is the count of `memory-write` / `memory-update` calls that succeeded. On error (a `memory-write` rejection, a missing tool), surface the reason and stop:
42
+
43
+ ```
44
+ db-op: error reason=<kebab-case-reason>
45
+ ```
46
+
47
+ No prose summary, no listing of names. The operator does not see this turn.
48
+
49
+ When the dispatch prompt has no `<conversation>` block, you are in discretionary inline mode — behaviour is unchanged from the rest of this document.
50
+
23
51
  ## Typed-edge auto-extraction pass (session-end)
24
52
 
25
53
  When admin's brief names the typed-edge pass (Task 305) and supplies a `sinceIso` timestamp, call `memory-typed-edge-pass` with that timestamp exactly once. The tool reads prose-bearing nodes (Message/Page/Meeting/KnowledgeDocument/Idea/Note/Post/Report — `:KnowledgeDocument` covers email threads via `source:'email'` since Task 321) the account wrote since `sinceIso`, asks Haiku for typed-edge proposals against a closed allowlist, validates each one, and MERGEs accepted edges with `createdBy` provenance. Return the full counter object verbatim — admin needs `nodesProcessed` and `accepted` for the `session-retrospective-mark-complete` call. Do not reinterpret the counts or summarise them.
@@ -145,20 +145,49 @@ Write a `no-op` (no Listing produced; admin agent informed) when:
145
145
 
146
146
  ## Image curation
147
147
 
148
- After writing the Listing, the curator writes one `:ImageObject` node per URL in `imageUrls` and a `[:DEPICTS]` edge from the Listing to each image. ImageObjects are what the public-agent recommender skill picks among when a visitor asks to see a specific room or feature — without them, the agent can only emit the hero image. The schema contract for `:ImageObject` lives in `schema-estate-agent.md`.
148
+ After writing the Listing, the curator writes one `:ImageObject` node per URL in `imageUrls` (plus one per `floorplanUrls` entry and one for `epcUrl`) and a `[:DEPICTS]` edge from the Listing to each image. ImageObjects are what the public-agent recommender skill picks among when a visitor asks to see a specific room or feature — without them, the agent can only emit the hero image. The schema contract for `:ImageObject` lives in `schema-estate-agent.md`.
149
149
 
150
- Image bytes are read with `Read` from the local file. Zip-site assets live at `<accountDir>/sites/<slug>/images/<filename>`; source-backed listings additionally have `<accountDir>/<source_dir>/images/<filename>`. The curator runs on the same Pi as the assets — never fetch via HTTPS. (For Loop CRM listings whose images are CDN-hosted, `Read` is not available; in that branch tag with an empty description and an empty tag set, and rely on the `description` field for room context.)
150
+ The closed tag set is `kitchen`, `bathroom`, `bedroom-master`, `bedroom-secondary`, `living-room`, `dining-room`, `garden-rear`, `garden-front`, `exterior-front`, `exterior-rear`, `hallway`, `utility`, `floorplan`, `epc`, `view`. Never invent a tag outside it. The literal tag `hero` is forbidden `heroIndex: 0` identifies the hero.
151
+
152
+ There are two metadata sources, picked per Listing:
153
+
154
+ ### Source A — brochure sidecar (the default on zip-site Listings)
155
+
156
+ The brochure pipeline that produced the published site already ran a deliberate vision pass over the same image bytes during slot verification and recorded the per-image semantics in `output/images.json`. The web bundle carries a byte-identical copy at the bundle root, and `publish-site` lands it at `<accountDir>/sites/<slug>/images.json`. The brochure's signal is strictly richer than a second per-image vision call would produce — slot semantics are operator-confirmed and the figcaptions are editorial sentences. Re-running vision over the same bytes is duplicate work that produces a worse output.
157
+
158
+ When `<accountDir>/sites/<slug>/images.json` exists, harvest from it for every image and **do not** `Read` any file under `<accountDir>/sites/<slug>/images/`:
159
+
160
+ 1. **Parse the sidecar once.** `Read` `<accountDir>/sites/<slug>/images.json`; validate `schemaVersion === 1` and that `images` is a non-empty array. On parse failure or schema mismatch, log `[listing-curator] images-sidecar-invalid reason=<schema|parse> path=<…>` and fall through to Source B (vision) for the whole Listing.
161
+ 2. **Match each `imageUrls[i]` to a sidecar entry by filename.** The filename is the URL's basename (`https://<publicHost>/sites/<slug>/images/griffin-house-01.webp` → `griffin-house-01.webp`). Each `floorplanUrls[i]` and the `epcUrl` matches the same way against entries whose `isFloorplan` / `isEpc` is `true`.
162
+ 3. **Populate the ImageObject from the matched entry.**
163
+ - `description` ← the sidecar's `caption` if present, else `altText`. Truncate at 200 chars on a word boundary; if the result is empty or all whitespace, `description = ""`.
164
+ - `tags` ← `[room]` for the matched entry — a one-element array drawn from the closed set. The mapping is operator-confirmed in the brochure; do not second-guess it. For floorplans `tags = ["floorplan"]`, for the EPC `tags = ["epc"]`, regardless of the entry's `room` value (which is also `floorplan` / `epc` by sidecar contract).
165
+ - `heroIndex` ← the image's 0-based position in `imageUrls`. The sidecar's `isHero` is informational only; `heroIndex` is the authoritative hero signal on the graph side.
166
+ 4. **Unmatched URLs.** When `imageUrls[i]` has no matching sidecar entry (filename drift between brochure WebP names and published URLs — a defect upstream), write the ImageObject anyway with `description: ""` and `tags: []`. Log `[listing-curator] image-sidecar-unmatched url=<url> filename=<basename>`. Counted in `imagesWritten - imagesFromBrochure` on the structured log; do not fall through to vision for the one unmatched image.
167
+
168
+ ### Source B — vision fallback (sidecar absent)
169
+
170
+ When `<accountDir>/sites/<slug>/images.json` does not exist (legacy brochures published before the sidecar contract landed, manual zip uploads that bypassed the brochure skill, or any future zip path that hasn't adopted the sidecar), the curator falls back to a per-image vision pass. This is the only branch that runs vision on listing-photo bytes.
171
+
172
+ Image bytes are read with `Read` from the local file. Zip-site assets live at `<accountDir>/sites/<slug>/images/<filename>`. The curator runs on the same Pi as the assets — never fetch via HTTPS.
151
173
 
152
174
  For each image on the Listing:
153
175
 
154
- 1. **Vision tag pass.** `Read` the local image file at the path derived from the URL (zip-site: `<accountDir>/sites/<slug>/images/<filename>` derived from the `https://<publicHost>/sites/<slug>/images/<filename>` URL). `Read` returns the bytes as an inline content block your model can see. Produce `{description, tags}` from what you see using the same single-call doctrine as the blurb step. The description is a single sentence (≤ 200 characters, plain English, no estate-agent clichés). The tags are up to four labels drawn from this fixed set: `kitchen`, `bathroom`, `bedroom-master`, `bedroom-secondary`, `living-room`, `dining-room`, `garden-rear`, `garden-front`, `exterior-front`, `exterior-rear`, `hallway`, `utility`, `floorplan`, `epc`, `view`. The tag set is closed — never invent a tag outside it. When no tag applies, `tags` is the empty array.
155
- 2. **Validate.** If `Read` fails (file missing), or the JSON fails to parse, or `description` is empty / >200 chars, write the ImageObject anyway with `description: ""` and `tags: []`. Log `[listing-curator] image-tag-failed url=<url> reason=<read-error|json-parse|description-bounds>`. One image's tagging failure must not block the rest of the Listing's images — degraded room-pick on that image is the only consequence.
156
- 3. **Write the node.** `MERGE (i:ImageObject {accountId, listingSlug, url})` then `SET i.description, i.tags, i.heroIndex, i.scope, i.sourceSystem`. `scope` mirrors the parent Listing (`public` for visitor-renderable Listings; `shared` otherwise). `heroIndex` is the image's 0-based position in the source `imageUrls` array — `0` for the hero.
157
- 4. **Write the edge.** `MERGE (l:Listing {accountId, slug:listingSlug})-[:DEPICTS]->(i)`. The MERGE on the natural key `(accountId, listingSlug, url)` makes both the node write and the edge write idempotent; re-running the curator over a Listing tops up missing images without duplicating existing ones.
176
+ 1. **Vision tag pass.** `Read` the local image file at the path derived from the URL (zip-site: `<accountDir>/sites/<slug>/images/<filename>`). `Read` returns the bytes as an inline content block your model can see. Produce `{description, tags}` from what you see using the same single-call doctrine as the blurb step. The description is a single sentence (≤ 200 characters, plain English, no estate-agent clichés). The tags are up to four labels drawn from the closed set above. When no tag applies, `tags` is the empty array.
177
+ 2. **Validate.** If `Read` fails (file missing), the JSON fails to parse, or `description` is empty / >200 chars, write the ImageObject anyway with `description: ""` and `tags: []`. Log `[listing-curator] image-tag-failed url=<url> reason=<read-error|json-parse|description-bounds>`. One image's tagging failure must not block the rest of the Listing's images — degraded room-pick on that image is the only consequence.
178
+
179
+ ### Loop CRM CDN-hosted images
180
+
181
+ For Loop CRM listings whose images are CDN-hosted (`b-cdn.net`), `Read` is not available — there is no local file. Skip both Source A and Source B for these images: tag with an empty description and an empty tag set, and rely on the parent Listing's `description` for room context. This branch is unchanged by Task 408.
182
+
183
+ ### Write the node and edge
184
+
185
+ Common to both sources:
158
186
 
159
- No `embedding` is written on `:ImageObject` v1. The recommender picks by reading the Listing's child ImageObjects from hybrid search's `related` set (graph-search expands one hop automatically). If pick quality is poor, a follow-up task will add per-image embeddings; not now.
187
+ - **Write the node.** `MERGE (i:ImageObject {accountId, listingSlug, url})` then `SET i.description, i.tags, i.heroIndex, i.scope, i.sourceSystem`. `scope` mirrors the parent Listing (`public` for visitor-renderable Listings; `shared` otherwise). `heroIndex` is the image's 0-based position in the source `imageUrls` array — `0` for the hero.
188
+ - **Write the edge.** `MERGE (l:Listing {accountId, slug:listingSlug})-[:DEPICTS]->(i)`. The MERGE on the natural key `(accountId, listingSlug, url)` makes both the node write and the edge write idempotent; re-running the curator over a Listing tops up missing images without duplicating existing ones.
160
189
 
161
- Floorplans and the EPC document follow the same path — they get their own `:ImageObject` with `tags: ["floorplan"]` or `tags: ["epc"]`, written from the curator's `floorplanUrls` and `epcUrl` fields. The hero image carries `tags` reflecting what the photo actually shows (e.g. `["exterior-front"]`) never the literal tag `hero`. `heroIndex: 0` identifies it.
190
+ No `embedding` is written on `:ImageObject` v1. The recommender picks by reading the Listing's child ImageObjects from hybrid search's `related` set (graph-search expands one hop automatically). Per-image embeddings are tracked separately under `.tasks/334-…` and are independent of this task.
162
191
 
163
192
  Hybrid search's 1-hop expand caps `related` at 20 neighbours per Listing. Listings with > 20 images lose the surplus to the cap — acceptable for v1 (most Loop listings ≤ 20). Log `[listing-curator] image-cap-warning slug=<slug> imageCount=<N>` when `N > 20` so the gap is visible.
164
193
 
@@ -183,7 +212,7 @@ The migration cypher is run once via SSH against the Pi as a manual backfill ste
183
212
  One structured log line per write, emitted by the curator before returning to the admin agent:
184
213
 
185
214
  ```
186
- [listing-curator] event=property-curated slug=<slug> sourceSystem=<system> sourceId=<id> action=<create|update|no-op> parentLabel=<Organization|—> parentBrandSlug=<brand|—> pageUrl=<url|—> fieldsFilled=<comma-separated-list> fieldsMissing=<comma-separated-list> scope=<scope> imagesWritten=<N> imagesTagged=<M> imagesFailed=<F>
215
+ [listing-curator] event=property-curated slug=<slug> sourceSystem=<system> sourceId=<id> action=<create|update|no-op> parentLabel=<Organization|—> parentBrandSlug=<brand|—> pageUrl=<url|—> fieldsFilled=<comma-separated-list> fieldsMissing=<comma-separated-list> scope=<scope> imagesWritten=<N> imagesTagged=<M> imagesFailed=<F> imagesFromBrochure=<S>
187
216
  ```
188
217
 
189
218
  For a `no-op` outcome that didn't even reach the write step (brand-unresolved, brand-ambiguous), emit the same line shape with `action=no-op`, `parentLabel=—`, `parentBrandSlug=—`, `imagesWritten=0`, and an additional `reason=` field naming the rejection:
@@ -194,7 +223,7 @@ For a `no-op` outcome that didn't even reach the write step (brand-unresolved, b
194
223
 
195
224
  `fieldsMissing` is the load-bearing signal on writes. Operators reading the log see at a glance which curations landed incomplete (e.g. `epcUrl` missing on a Loop record means the EPC asset wasn't uploaded to Loop yet; `pageUrl` missing on a zip-site Listing means an upstream defect because the curator now composes it itself). Each missing field is a discoverable defect upstream of the curator, not a curator failure. `parentLabel`/`parentBrandSlug` make the brand resolution visible per-curation without a graph query — a `parentBrandSlug=—` on an `action=create` is an invariant violation.
196
225
 
197
- `imagesWritten` is the total `:ImageObject` count for the Listing after the run. `imagesTagged` is the count with non-empty `tags`. `imagesFailed` counts images where the local `Read` or the JSON-validation failed and a placeholder ImageObject was written with empty description and tags. A high `imagesFailed / imagesWritten` ratio on zip-site Listings means images are missing from `<accountDir>/sites/<slug>/images/` — investigate the publish step, not the vision prompt. When `imagesWritten > 20`, the recommender will only see the first 20 in its `related` set (one-hop expand cap) — emit a separate `[listing-curator] image-cap-warning slug=<slug> imageCount=<N>` line so the cap is visible.
226
+ `imagesWritten` is the total `:ImageObject` count for the Listing after the run. `imagesTagged` is the count with non-empty `tags`. `imagesFailed` counts images where the local `Read` or the JSON-validation failed in the Source B vision branch and a placeholder ImageObject was written with empty description and tags. `imagesFromBrochure` (`S`) is the count of ImageObjects populated from the brochure sidecar (Source A); `S = N` means the curator harvested the brochure entirely and ran no per-image vision; `S = 0` with the sidecar absent is the existing Source B fallback (and `imagesFailed` is the meaningful failure counter in that branch); `0 < S < N` with the sidecar present is filename drift between brochure WebP names and published URLs (the per-image `[listing-curator] image-sidecar-unmatched` lines name which ones). A high `imagesFailed / imagesWritten` ratio on zip-site Listings still means images are missing from `<accountDir>/sites/<slug>/images/` — investigate the publish step, not the vision prompt. When `imagesWritten > 20`, the recommender will only see the first 20 in its `related` set (one-hop expand cap) — emit a separate `[listing-curator] image-cap-warning slug=<slug> imageCount=<N>` line so the cap is visible.
198
227
 
199
228
  ## Out of scope
200
229
 
@@ -193,6 +193,7 @@ If one or more candidates from the closed list exist, the operator confirmation
193
193
  - **`scrollHeight > viewport budget` is ALWAYS a defect — never "negligible".** Landscape budget is **794px** (`min-height: 210mm` at 96dpi); portrait is **1123px**. Every `.page`'s `scrollHeight` must be `≤` the budget — strict, not "approximately equal". A 4px overflow IS a defect: `overflow: hidden` silently clips that excess, and on pages with absolute-positioned bottom chrome (`.folio`, `.back-disclaimer`) the clipped content paints over chrome that lives in the absolute layer. "It's only 4 pixels" is the rationalisation that ships defects — the rule does not have a tolerance band. If a page reports `scrollHeight > budget` even by 1px, the build halts until the operator addresses the content-volume mismatch (trim copy, shrink an image, reduce a row count, or split into a continuation page). Never paper over with `overflow: hidden`-as-clip; the warning sign is the deliverable's only signal that the content doesn't fit.
194
194
  - **Material Information table has a hard row capacity — overflow is a content-volume defect, not a render bug.** At the default `.mi-row` sizing (9.5/11.5px fonts, 6px vertical padding, 18px `.mi-grid` row-gap), one column in the three-column MI grid fits **~13 rows** within the 22mm-clearance budget. A property with an unusually full table (e.g. 17+ rows in the tallest column, observed on a recent run) overruns by ~25mm / ~85px and clips into the folio zone. When MI's content volume exceeds the budget, the operator confirmation prompt MUST surface `mi.<column>.row_count` as an overflow item and ask the operator to either (a) trim the column to ≤ 13 rows by consolidating related disclosures (e.g. merge "easements" + "water/sewerage" + "rights of way" into one "Other matters" row that points at the seller's solicitor), or (b) opt into a continuation page (15a / 15b) — a separate skill-level decision that breaks the default 16-page contract and must be operator-confirmed. The build never silently truncates rows, never shrinks fonts to "make it fit", and never accepts a scrollHeight overflow as "negligible". The default `.mi-page` 22mm bottom-padding is sized for the typical property; properties outside the typical range MUST resolve via operator confirmation, not via the agent's judgement about which rows are "less important".
195
195
  - **No `{{ token }}` may remain in rendered output** — `grep '{{' output/brochure.html` must return zero matches before PDFs are built.
196
+ - **`images.json` sidecar is mandatory.** After slot verification and before bundling, write `output/images.json` and `output/web/images.json` (byte-identical) per `references/images.md → images.json sidecar`. The downstream `:Listing` curator reads the bundle copy at `<accountDir>/sites/<slug>/images.json` after publish and uses it in place of running a second per-image vision pass. A bundle without `images.json` forces the curator into its fallback path and costs one vision call per photo — silent on the brochure side, observable as `imagesFromBrochure=0` on the curator's log line.
196
197
 
197
198
  ## Scope
198
199
 
@@ -226,6 +226,14 @@ The web PDF (`<slug>-brochure-web.pdf` at the property level) is also placed ins
226
226
  cp output/<slug>-brochure-web.pdf output/web/<slug>-brochure.pdf
227
227
  ```
228
228
 
229
+ ### Write the `images.json` sidecar
230
+
231
+ After the web bundle's `images/` is populated and the brochure HTML is copied in, write the sidecar file at **both** `output/images.json` and `output/web/images.json`. The contents are byte-identical — the canonical archive copy and the bundle copy. Source-of-truth is the slot-verification dict already in memory; never re-derive from disk.
232
+
233
+ Full schema, closed-set room vocabulary, slot → room mapping, and emission recipe live in [`images.md → images.json sidecar`](images.md). The downstream `:Listing` curator reads the bundle copy at `<accountDir>/sites/<slug>/images.json` after `publish-site` lands the tree.
234
+
235
+ The strip-unreferenced-images pass below scans `output/web/images/` only — `images.json` lives at the bundle root and is unaffected.
236
+
229
237
  ### Strip unreferenced images
230
238
 
231
239
  Every image in `output/web/images/` must be reachable from at least one HTML surface in the bundle (`brochure.html`, the companion landing page, and `og.html` if present). The build accumulates working images during editing — operator drops a hero, re-numbers a sequence, swaps an image and forgets to delete the prior version — and without the strip step the bundle ships every accumulated file. Typical bloat on a re-iterated 16-page folio is 20–40 unreferenced `.webp` files at ~500 KB each, which is what pushes a "30–50 MB target" bundle to 80–120 MB.
@@ -273,7 +281,7 @@ Serve the unzipped bundle from an isolated temp directory and verify every refer
273
281
  TMP=/tmp/web-test && rm -rf $TMP && mkdir -p $TMP
274
282
  cd $TMP && unzip -q /path/to/<slug>-web.zip
275
283
  python3 -m http.server 8765 &
276
- for f in brochure.html index.html images/<slug>-01.webp images/<brand>-logo-light.png <slug>-brochure.pdf; do
284
+ for f in brochure.html index.html images.json images/<slug>-01.webp images/<brand>-logo-light.png <slug>-brochure.pdf; do
277
285
  echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/$f) $f"
278
286
  done
279
287
  ```
@@ -291,6 +299,7 @@ The brochure should render the same as the canonical preview, just lighter on th
291
299
  | `<slug>-brochure.pdf` | — | ✓ identical bytes to `-web.pdf`; matches index.html / brochure.html link |
292
300
  | `cover-print.png … backpage-print.png` | ✓ 300 dpi PNG (~4–7 MB each) — canonical snapshots | — bundle ships the PDF instead of per-page JPGs |
293
301
  | `images/<slug>-NN.webp` | full-quality per Render-slot table | web-tier per the web table above |
302
+ | `images.json` | ✓ canonical sidecar (slot, room, alt, caption per image) — written after slot verification | ✓ byte-identical copy at bundle root; consumed by `:Listing` curator after publish |
294
303
  | `images/qr-*.png`, `images/<brand>-logo-*.png` | ✓ | ✓ (copied unchanged — already small) |
295
304
  | `.snapshots-web/*-print.png` | ✓ intermediate (192 dpi PNGs used by the web-PDF build; deletable after) | — |
296
305
 
@@ -75,6 +75,110 @@ Rename all photos to `{property-slug}-NN.webp` in an `images/` subfolder. The fo
75
75
 
76
76
  Indexes 03, 11–12, 16–17, 20–23, 28+ are intentionally unused — leaving gaps means a property with more rooms can use them without renumbering. Adapt the page layout when images are missing — remove the slot, don't leave a placeholder.
77
77
 
78
+ ## `images.json` sidecar — emit alongside the brochure
79
+
80
+ After slot verification is complete (every WebP under `output/images/` has been read and assigned a slot by pixel content per the rule above), write `output/images.json` as the deterministic record of what the brochure resolved per image. The data is already in hand at this point — the slot table, the alt-text token values, and the figcaption token values are the same strings the brochure HTML carries. The downstream `:Listing` curator consumes this sidecar to populate `:ImageObject.description` and `:ImageObject.tags` without re-running vision against the same bytes. Without the sidecar the curator falls back to a per-image vision call, paying a second time for metadata the brochure has already produced.
81
+
82
+ Write a second copy at `output/web/images.json` immediately after copying the brochure into `output/web/`. The web bundle is what gets zipped, unzipped at `<accountDir>/extracted/<id>/`, and published via `publish-site` to `<accountDir>/sites/<slug>/` — the curator reads it there. The canonical copy at `output/images.json` is the archival twin and never gets re-derived from the bundle copy. Both copies must be byte-identical at write time; never write one without the other.
83
+
84
+ ### Schema
85
+
86
+ ```jsonc
87
+ {
88
+ "schemaVersion": 1,
89
+ "images": [
90
+ {
91
+ "filename": "griffin-house-01.webp",
92
+ "slot": 1,
93
+ "room": "exterior-front",
94
+ "altText": "Principal elevation of Griffin House at golden hour.",
95
+ "caption": "The house, returning to itself at dusk.",
96
+ "isHero": true,
97
+ "isFloorplan": false,
98
+ "isEpc": false
99
+ }
100
+ ]
101
+ }
102
+ ```
103
+
104
+ One entry per WebP referenced by `brochure.html`, plus one entry for the floorplan PNG and one for the EPC asset. Brand logos (`<brand>-logo-*.png`) and QR PNGs (`qr-*.png`) are not images of the property — exclude them.
105
+
106
+ | Field | Source |
107
+ |---|---|
108
+ | `filename` | the file's basename inside `images/` (e.g. `griffin-house-01.webp`, `griffin-house-floorplan.png`, `epc.png`) — no leading directory |
109
+ | `slot` | the index from the slot table (1, 2, 4, 5, 6, 7, 8, 9, 10, 13, 14, 15, 18, 19, 24, 25, 26, 27). Floorplan is slot 25; EPC has no slot — set to `null` |
110
+ | `room` | one value from the closed set below — the brochure's per-slot semantic |
111
+ | `altText` | the `<img alt="…">` factual sentence the brochure assigned to this image (`opener_image_alt`, `landing_grid_N_alt`, `hero_N_alt`, etc.) |
112
+ | `caption` | the `<figcaption>` editorial sentence the brochure assigned (`chapter2_strip_N_caption_text`, `intermission_05_caption`, etc.). Omit when the slot carries no figcaption (e.g. cover, gallery thumbs without captions); the curator falls back to `altText` |
113
+ | `isHero` | `true` only for slot 1 (the cover hero). Every other entry is `false` |
114
+ | `isFloorplan` | `true` only for the floorplan PNG (slot 25) |
115
+ | `isEpc` | `true` only for the EPC asset |
116
+
117
+ ### Closed `room` set
118
+
119
+ The room vocabulary is the same closed 15-element set the `listing-curator` writes onto `:ImageObject.tags`. Never invent a value outside it.
120
+
121
+ ```
122
+ kitchen, bathroom, bedroom-master, bedroom-secondary,
123
+ living-room, dining-room, garden-rear, garden-front,
124
+ exterior-front, exterior-rear, hallway, utility,
125
+ floorplan, epc, view
126
+ ```
127
+
128
+ The slot table is the canonical mapping. The natural slot → room translation is:
129
+
130
+ | Slot | Role | `room` |
131
+ |---|---|---|
132
+ | 1 | Cover hero (golden-hour exterior) | `exterior-front` |
133
+ | 2 | Principal front elevation | `exterior-front` |
134
+ | 4 | Open-plan reception or kitchen-dining hero | `kitchen` (when kitchen-dominant) or `living-room` |
135
+ | 5 | Open reception or hero room | `living-room` |
136
+ | 6 | Kitchen / breakfast room | `kitchen` |
137
+ | 7 | Kitchen wide or patio doors | `kitchen` |
138
+ | 8 | Separate living / sitting room | `living-room` |
139
+ | 9 | Entrance hallway | `hallway` |
140
+ | 10 | Garden room / sun room / second reception | `living-room` |
141
+ | 13 | Principal bedroom | `bedroom-master` |
142
+ | 14 | Shower room or secondary bathroom | `bathroom` |
143
+ | 15 | Bedroom two | `bedroom-secondary` |
144
+ | 18 | Bedroom three | `bedroom-secondary` |
145
+ | 19 | Family bathroom | `bathroom` |
146
+ | 24 | Aerial / drone / wide garden hero | `garden-rear` |
147
+ | 25 | Composite floor plan | `floorplan` |
148
+ | 26 | Back page — atmospheric rear or aerial | `exterior-rear` |
149
+ | 27 | Lifestyle break — full-bleed wide-aspect aerial | `view` |
150
+
151
+ The disambiguation at slots 4 and 10 is the brochure's call — read the assigned image and pick the dominant subject. The closed set never grows mid-run; if a slot genuinely depicts something outside the vocabulary (e.g. a utility room), use the nearest closed-set value (`utility`) and explain in `altText`. The closed set is the curator's tag vocabulary; widening it on one side and not the other breaks the contract.
152
+
153
+ ### Emission
154
+
155
+ Write both files in the same step:
156
+
157
+ ```bash
158
+ # After slot verification, alt-text token resolution, and figcaption token resolution.
159
+ # Use the python helper or inline JSON write — the data is already in the substitution dict.
160
+ python3 - <<'PY'
161
+ import json, pathlib
162
+ images = [ … one dict per image, populated from the substitution dict above … ]
163
+ payload = {"schemaVersion": 1, "images": images}
164
+ for path in ("output/images.json", "output/web/images.json"):
165
+ pathlib.Path(path).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
166
+ PY
167
+ ```
168
+
169
+ The `output/web/` copy is written **after** the bundle's `images/` is populated and the brochure HTML is copied in — not before, so the file survives any earlier cleanup steps that wipe `output/web/`. Place the emission step in the build flow immediately before the strip-unreferenced-images pass; the strip works against `output/web/images/` only and never touches `images.json` at the bundle root.
170
+
171
+ ### Observability
172
+
173
+ On successful sidecar emission, log:
174
+
175
+ ```
176
+ [property-brochure] images-sidecar-written count=<N> path=<output/images.json>
177
+ [property-brochure] images-sidecar-written count=<N> path=<output/web/images.json>
178
+ ```
179
+
180
+ A `count=0` line surfaces a brochure that referenced zero property images — already a defect by other rules, now visible at sidecar time too.
181
+
78
182
  ## Output folder structure
79
183
 
80
184
  All final artefacts go in **exactly one** location: `<property_dir>/output/`, where `<property_dir>` is `<brand_dir>/properties/<property_slug>-<id>/`. The folder is the deliverable; no nested `brochure/` wrapper between `<property_dir>/` and `output/`, no flat HTML/PDF at the `<property_dir>/` level.