@rubytech/create-maxy-code 0.1.485 → 0.1.486

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 (91) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +124 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +107 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +205 -0
  5. package/payload/platform/lib/graph-style/dist/index.d.ts +4 -1
  6. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/graph-style/dist/index.js +109 -1
  8. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  9. package/payload/platform/lib/graph-style/src/__tests__/caption-coverage.test.ts +117 -0
  10. package/payload/platform/lib/graph-style/src/index.ts +81 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +159 -0
  12. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +18 -6
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +141 -0
  14. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-pre.sh +66 -0
  15. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +33 -1
  16. package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +3 -3
  17. package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -0
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +42 -3
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  33. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  34. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  35. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  36. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  37. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  38. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  48. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  49. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  50. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  52. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  53. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  55. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  56. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  57. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  59. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  60. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  61. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  62. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  63. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  64. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  65. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  66. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  67. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +299 -0
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  71. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  73. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  75. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  78. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  79. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  80. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  81. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  82. package/payload/server/maxy-edge.js +1 -1
  83. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  84. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  85. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  86. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  87. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  88. package/payload/server/public/data.html +3 -3
  89. package/payload/server/public/graph.html +2 -2
  90. package/payload/server/public/index.html +3 -3
  91. package/payload/server/server.js +84 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.485",
3
+ "version": "0.1.486",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -0,0 +1,124 @@
1
+ # Graph Caption Resolver Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make every top-level operator-entry graph label resolve to a human-readable caption instead of its bare type, and add a build-time gate that fails when a new styled label ships without a caption decision.
6
+
7
+ **Architecture:** Add per-label caption branches to `pickShortLabel` and `pickDisplayName` in `platform/lib/graph-style/src/index.ts` for the first-class labels that currently fall through to `return primaryLabel`. Add a `caption-coverage.test.ts` that reconciles every `GRAPH_LABEL_COLOURS` key against a `CAPTION_COVERAGE` fixture, so registering a styled colour without deciding its caption is a red build.
8
+
9
+ **Tech Stack:** TypeScript, vitest 4 (run via `platform/ui/node_modules/.bin/vitest`), no new dependencies.
10
+
11
+ ## Global Constraints
12
+
13
+ - Do not touch `GRAPH_LABEL_COLOURS` or `LABEL_ICONS` — every label already has both.
14
+ - `pickShortLabel` truncates to 24 chars (`slice(0, 24) + '…'`); `pickDisplayName` returns the untruncated value. New branches follow that per-function convention.
15
+ - Caption source is a scalar property already written on the node itself; never an id that points at another node, never a composed value that needs a second node.
16
+ - The UI parity test `platform/ui/app/graph/__tests__/display-helpers.test.ts` imports graph-style's **built** `dist/index.js`, so graph-style must be rebuilt (`tsc -p tsconfig.json`) before that test is rerun.
17
+ - `SHAPE_BY_LABEL` assigns shapes by key order in `GRAPH_LABEL_COLOURS`; do not reorder or insert keys.
18
+
19
+ ---
20
+
21
+ ## Label classification (the crux)
22
+
23
+ The resolver captions a node when: (1) its primary label has an explicit branch, or (2) the node carries one of the generic-fallback keys `name, title, summary, givenName, subject, text`, or (3) it is the `Conversation` special-case. Otherwise it returns the bare type.
24
+
25
+ **Already captioned by an existing branch** (unchanged): `ToolCall, WorkflowRun, WorkflowStep, Person, Agent, Job, Quote, InboundInvoice, WhatsAppGroup, CashEntry, Message`, plus the `Conversation` `sessionId` special-case.
26
+
27
+ **Already captioned by the generic fallback** (they declare a fallback key): `LocalBusiness, Service, Organization, Customer, Site, Asset, Part, PpmContract, DigitalDocument, Question, FAQPage, DefinedTerm, Task, Event, ImageObject` (via `name`); `CreativeWork, ConversationArchive, Objective, KeyResult, Decision, Source` (via `title`); `Note` (via `text`).
28
+
29
+ **First-class fall-throughs that need a NEW branch** (top-level operator-entry per schema, no fallback key, no existing branch):
30
+
31
+ | Label | Caption field(s) | Schema evidence |
32
+ |-------|------------------|-----------------|
33
+ | `Property` | `address` → `slug` | schema-estate-agent top-level; required `slug`, `address` |
34
+ | `Listing` | `displayName` → `slug` | schema-estate-agent top-level; required `slug`, `displayName` |
35
+ | `Viewing` | `date` | schema-estate-agent top-level; local scalar `date` |
36
+ | `Offer` | `status` then `price` composed as `status £price` when both present, else whichever is present | schema-estate-agent top-level; local scalars `status`, `price` |
37
+ | `Visit` | `purpose` → `status` | schema-construction top-level; local scalars `purpose`, `status` |
38
+ | `PurchaseOrder` | `poNumber` | schema-construction top-level; natural key `poNumber` |
39
+ | `Invoice` | `confirmationNumber` | schema-base first-class financial; `confirmationNumber` (mirror of the `InboundInvoice` branch's identity) |
40
+ | `Risk` | `statement` | schema-knowledge-work; local `statement` |
41
+ | `Finding` | `statement` | schema-knowledge-work; local `statement` |
42
+ | `Hypothesis` | `statement` | schema-knowledge-work; local `statement` |
43
+
44
+ **Grey allowlist — render as bare type by design** (child/specification/platform-plumbing labels, not first-class viewer entities): `PriceSpecification, OpeningHoursSpecification, Section, Chunk, Review, Preference, UserProfile, AdminUser, AccessGrant, StepResult`.
45
+
46
+ **Empirically finalised during implementation** (their bucket depends on their actual write shape, decided by building a synthetic node from the label's real properties and observing `pickShortLabel`): `KnowledgeDocument, Project, Workflow, Email, EmailAccount, UserMessage, AssistantMessage, AdminConversation, PublicConversation`. Rule: if the label declares a fallback key (`name/title/summary/subject/text`) it goes in the captioned bucket with that field as `expected`; otherwise it is grey. Every one of these must land in exactly one bucket before Task 2 passes — none may be left undeclared.
47
+
48
+ ---
49
+
50
+ ## Task 1: Caption-coverage gate (Part B) — written first, RED
51
+
52
+ **Files:**
53
+ - Create: `platform/lib/graph-style/src/__tests__/caption-coverage.test.ts`
54
+
55
+ **Interfaces:**
56
+ - Consumes: `GRAPH_LABEL_COLOURS`, `pickShortLabel` from `../index`.
57
+ - Produces: nothing consumed by later tasks; it is the verifier for Task 2.
58
+
59
+ - [ ] **Step 1: Write the coverage test with the full fixture**
60
+
61
+ The test owns `CAPTION_COVERAGE: Record<string, { node: GraphNodeLike; expected: string } | { fallbackGrey: true }>`, one entry per key of `GRAPH_LABEL_COLOURS`. Captioned entries carry a synthetic node built from the label's identity fields and the `expected` caption. Grey entries are `{ fallbackGrey: true }`. Seed from the classification table above.
62
+
63
+ Assertions:
64
+ 1. **Completeness.** `Object.keys(GRAPH_LABEL_COLOURS)` and `Object.keys(CAPTION_COVERAGE)` are set-equal — every styled label has an entry, and no entry names an unstyled label.
65
+ 2. **Correctness.** For each non-grey entry, `pickShortLabel(entry.node) === entry.expected` and `pickShortLabel(entry.node) !== label`.
66
+
67
+ - [ ] **Step 2: Run it — expect RED**
68
+
69
+ Run: `cd platform/lib/graph-style && ./node_modules/.bin/vitest run src/__tests__/caption-coverage.test.ts`
70
+ Expected: FAIL — the correctness assertion fails for `Property, Listing, Viewing, Offer, Visit, PurchaseOrder, Invoice, Risk, Finding, Hypothesis` (each still returns its bare type).
71
+
72
+ - [ ] **Step 3: Commit the RED test**
73
+
74
+ ```bash
75
+ git add platform/lib/graph-style/src/__tests__/caption-coverage.test.ts
76
+ git commit -m "test(graph-style): caption-coverage gate (red)"
77
+ ```
78
+
79
+ ## Task 2: Caption branches (Part A) — turn it GREEN
80
+
81
+ **Files:**
82
+ - Modify: `platform/lib/graph-style/src/index.ts` — `pickShortLabel` (before the generic-fallback loop, ~line 494) and `pickDisplayName` (before its generic-fallback loop, ~line 560).
83
+
84
+ **Interfaces:**
85
+ - Consumes: the fixture from Task 1 as its pass condition.
86
+ - Produces: caption branches for the ten fall-through labels; both functions keep their existing signatures.
87
+
88
+ - [ ] **Step 1: Add the ten branches to `pickShortLabel`**
89
+
90
+ Each branch reads the label's caption field(s) as `string`, returns the 24-char-truncated value when non-empty, and otherwise falls through to the next fallback. `Offer` composes `status` and `£price`. Placed in the existing `else if` chain before the `for (const k of [...])` loop.
91
+
92
+ - [ ] **Step 2: Add the same ten branches to `pickDisplayName`, untruncated**
93
+
94
+ Identical field logic, returning the full value (no `slice`), matching that function's convention.
95
+
96
+ - [ ] **Step 3: Finalise the empirical bucket for the nine ambiguous labels**
97
+
98
+ For `KnowledgeDocument, Project, Workflow, Email, EmailAccount, UserMessage, AssistantMessage, AdminConversation, PublicConversation`, build a synthetic node from the label's real write shape, run `pickShortLabel`, and place each in the captioned or grey bucket of `CAPTION_COVERAGE` per the rule. Add a branch only if the label is a first-class entity that falls through (none expected; if one appears, it joins Part A).
99
+
100
+ - [ ] **Step 4: Run the coverage test — expect GREEN**
101
+
102
+ Run: `cd platform/lib/graph-style && ./node_modules/.bin/vitest run`
103
+ Expected: PASS — all graph-style tests including `caption-coverage.test.ts`.
104
+
105
+ - [ ] **Step 5: Rebuild dist and run the UI parity test — expect GREEN**
106
+
107
+ Run: `cd platform/lib/graph-style && ./node_modules/.bin/tsc -p tsconfig.json && cd ../../ui && ./node_modules/.bin/vitest run app/graph/__tests__/display-helpers.test.ts`
108
+ Expected: PASS (77 tests) — parity between the canvas renderer and the live page holds.
109
+
110
+ - [ ] **Step 6: Commit Part A**
111
+
112
+ ```bash
113
+ git add platform/lib/graph-style/src/index.ts platform/lib/graph-style/src/__tests__/caption-coverage.test.ts
114
+ git commit -m "fix(graph-style): caption branches for top-level fall-through labels"
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Self-review
120
+
121
+ - **Spec coverage.** Task file Part A (Property/Listing branches) → Task 2 Steps 1-2, expanded to all fall-throughs per the approved scope decision. Task file Part B (coverage gate) → Task 1. Verification section (reproduction check, parity, display-helpers) → Task 2 Steps 4-5.
122
+ - **Scope deviation from the task file.** The task named only Property and Listing for Part A; the coverage seed surfaced eight more first-class fall-throughs (`Viewing, Offer, Visit, PurchaseOrder, Invoice, Risk, Finding, Hypothesis`), and the operator chose to branch all of them now. This is a deliberate, approved expansion, recorded in Plan Conformity at land time.
123
+ - **Correction to the task file.** The task listed `Note` as a grey example; `Note` declares `text`, a generic-fallback key, so it captions via `text` and is in the captioned bucket, not grey.
124
+ - **No placeholders.** The nine empirically-finalised labels have an explicit decision rule and a hard requirement to land in one bucket; they are not left "TBD".
@@ -0,0 +1,107 @@
1
+ # Task 1877 — Bash schema enforcement + standing reconcile — implementation plan
2
+
3
+ Closes the Bash channel gap in account-dir schema enforcement. Two layers, per
4
+ the approved task decision. No agent-tool narrowing, no command-string parsing,
5
+ no auto-repair.
6
+
7
+ ## Layer A — immediate Bash stray detection (two hooks)
8
+
9
+ Both live in `platform/plugins/admin/hooks/`, beside `fs-schema-guard.sh`. Both
10
+ key off account-root state (`$PWD` is the account dir for these hooks), never off
11
+ the command string.
12
+
13
+ ### `fs-schema-guard-bash-pre.sh` (PreToolUse, matcher `Bash`)
14
+ - Read stdin envelope, extract `tool_name` and `session_id` (python3, matching
15
+ `fs-schema-guard.sh`'s `field()` helper and the session-id extraction in
16
+ `askuserquestion-channel-carrier-gate.sh`).
17
+ - Non-`Bash` tool: `exit 0`.
18
+ - Resolve `TMP_DIR` with the exact `TMPDIR:-TMP:-TEMP:-/tmp` order and trailing-
19
+ slash strip used by `askuserquestion-channel-carrier-gate.sh`; sanitise the
20
+ session id `[^A-Za-z0-9_-]` to `_`.
21
+ - List the account root's immediate top-level entry names (one per line, python3
22
+ `os.listdir`) and write to `$TMP_DIR/maxy-fsguard-snapshot-<sid>.txt`.
23
+ - Fail open on every error (no session id, no python3, unlistable dir, unwritable
24
+ tmp): `exit 0`, take no snapshot. A snapshot that cannot be taken must not
25
+ block the command.
26
+
27
+ ### `fs-schema-guard-bash-post.sh` (PostToolUse, matcher `Bash`)
28
+ - Same envelope parse; non-`Bash`: `exit 0`.
29
+ - Read the pre-snapshot for this session; missing snapshot: `exit 0` (fail open).
30
+ - Parse `allowed-top-level` from `$PWD/SCHEMA.md` with the same awk extraction
31
+ `fs-schema-guard.sh` uses; empty/absent allowed set: `exit 0` (fail open — an
32
+ un-seeded legacy account must not have every Bash call blocked).
33
+ - Re-list the account root now. Strays = entries **present now AND absent from
34
+ the pre-snapshot AND not in the allowed set**.
35
+ - No strays: `exit 0` silent.
36
+ - For each stray, log `[fs-guard-bash] stray path=<name> reason=top-level` to
37
+ stderr; then `exit 2` with a schema-citing message telling the agent to
38
+ relocate through the `data-manager` specialist. No filesystem mutation
39
+ (PostToolUse runs after the command; auto-repair is rejected).
40
+
41
+ ## Layer B — standing reconcile
42
+
43
+ ### `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts`
44
+ Shape mirrors `rootless-client-audit.ts`: pure formatter (unit-tested) + impure
45
+ collector + `create…Reconcile` start/stop factory with an unref'd `setInterval`.
46
+ Purely filesystem: no Neo4j, no graph writes, no deletes.
47
+
48
+ Per account under the accounts root, per run:
49
+ - **Stray top-level:** any root entry not in that account's `allowed-top-level`
50
+ set (parsed from its `SCHEMA.md`). Accounts with no/empty allowed set are
51
+ skipped (fail open, like the hook).
52
+ - **Over-deep:** under the depth-checked buckets only, mirroring
53
+ `fs-schema-guard.sh`'s `case` statement exactly — `projects`/`contacts` flat to
54
+ 3 segments, `documents` to 3; every other (tool-owned) bucket is not walked.
55
+ This is the reuse of the guard's own encoding the task calls for, not a new
56
+ list.
57
+ - **Quarantine, never repair:** move each stray top-level entry into the
58
+ account's `.quarantine/` with a `manifest.jsonl` line recording original path +
59
+ reason + timestamp. Over-deep files are counted only (not moved and not
60
+ manifest-logged): moving a file out of a flat bucket would orphan it from its
61
+ entity folder; re-filing is `data-manager`'s job.
62
+ - **Control-plane safety belt:** the reconcile never quarantines the platform
63
+ control-plane entries (`.git`, `.claude`, `.quarantine`, `SCHEMA.md`,
64
+ `account.json`, `AGENTS.md`) regardless of the account's allowed set, so a
65
+ stale or hand-edited SCHEMA.md cannot drive it to move the account's git root,
66
+ hook settings, or identity file. This is a deliberate deviation from the task's
67
+ strict "any entry not in allowed" wording, chosen because a 5-minute auto-move
68
+ loop that can brick an account on a stale schema is an unacceptable foot-gun.
69
+ - Emit one line per run: `[fs-reconcile] stray-top-level=<n> over-deep=<m>
70
+ quarantined=<q>`.
71
+
72
+ Timestamps come from an injected clock (the module never calls `Date.now()` at
73
+ import; the factory passes `() => Date.now()`), so the pure formatter and
74
+ collector stay deterministic under test.
75
+
76
+ ### Wiring
77
+ - `index.ts`: construct `createAccountDirSchemaReconcile({ accountsRoot:
78
+ dirname(config.accountDir), logger: log, intervalMs: 300_000 })` beside the
79
+ sibling audits and call `.start()`.
80
+
81
+ ## Registration + seeding
82
+ - `provision-account-dir.sh`: add `fs-schema-guard-bash-pre.sh` to the existing
83
+ PreToolUse `Bash` matcher (after `archive-ingest-surface-gate.sh`); add a new
84
+ PostToolUse `Bash` matcher carrying `fs-schema-guard-bash-post.sh`.
85
+ - `templates/account-schema/SCHEMA.md`: add `.quarantine` to the
86
+ `allowed-top-level` block so the reconcile's own move never creates a stray.
87
+
88
+ ## Tests
89
+ - `platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh` — pre
90
+ writes snapshot; post blocks a whim folder with the log line; allowed
91
+ top-level and nested-allowed produce no block; no-new-entry is silent; a
92
+ pre-existing stray is not re-flagged; fail-open on missing snapshot / empty
93
+ allowed set.
94
+ - `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`
95
+ — formatter output; planted stray moved to `.quarantine/` with manifest;
96
+ allowed dir and tool-owned deep files untouched; over-deep counted not moved;
97
+ clean account logs zeros and moves nothing.
98
+
99
+ ## Verification surface
100
+ Build (`tsc`), lint, the new + existing `fs-schema-guard` tests, the manager
101
+ vitest suite. Installer publish awaits explicit operator command (sprint gating).
102
+
103
+ ## Out of scope (per task)
104
+ Graph-reconcile/broken-refs passes (original 1092 graph half); auto-re-filing
105
+ quarantined content; agent-tool narrowing; command-string parsing; `chattr`
106
+ immutability; one-time cleanup of the existing backlog (a `data-manager`
107
+ dispatch).
@@ -0,0 +1,205 @@
1
+ # Task 1887 — Reconcile `allowed-top-level` to the ontology Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `merge()` in `account-schema-owned-dirs.py` rebuild the account's `allowed-top-level` list each run from `fixed-seed ∪ plugin-owned ∪ ontology-buckets`, pruning a stale entry only when its on-disk dir is empty or absent and keeping-and-logging it when it holds files.
6
+
7
+ **Architecture:** `merge()` currently appends owned+domain dirs into the fenced `allowed-top-level` block and never removes. Replace the append with a reconcile: keep every current entry that is still in the target set, keep a stale entry that holds files (logged as `keptPopulated`), drop a stale entry that is empty/absent (logged as `removed`), then append newly-projected owned/domain dirs as before. A hardcoded fixed-seed set shields base template dirs from pruning.
8
+
9
+ **Tech Stack:** Python 3 (stdlib only), bash test harness (`__tests__/*.test.sh`).
10
+
11
+ ## Global Constraints
12
+
13
+ - Fixed seed set (prune-protection allowlist, verbatim from `platform/docs/superpowers/plans/2026-06-23-account-filesystem-schema.md:65-89`): `projects contacts documents url-get output generated extracted uploads agents specialists sites public cache secrets state logs tmp SCHEMA.md account.json AGENTS.md .claude .git .quarantine`. This is 23 entries and includes `url-get` (the task body's inline list dropped it; the operator confirmed the plan-doc list governs).
14
+ - The seed set is prune-protection only. `merge()` never *adds* a missing seed entry; seed membership only prevents an entry already in the list from being flagged stale.
15
+ - "Holds files" = at least one regular file exists anywhere under `account_dir/<entry>` (recursive), or `<entry>` is a non-empty regular file. Absent path, empty dir, or dir whose whole subtree contains no files → prunable. Strict reading: never silently drop a path that carries any file content.
16
+ - Summary line format (single line, extends the existing one): `brand=<b> added=<…|none> domainBuckets=<…|none> removed=<…|none> keptPopulated=<…|none>`.
17
+ - Idempotency is mandatory: two consecutive `merge()` runs produce a byte-identical `SCHEMA.md`.
18
+ - Out of scope: `account-dir-schema-reconcile.ts` (1877), `fs-schema-guard.sh`, `schema-exposed-dirs.mjs`, and disposition of existing populated strays. Do not touch them.
19
+
20
+ ---
21
+
22
+ ### Task 1: Reconcile in `merge()` + extended log
23
+
24
+ **Files:**
25
+ - Modify: `platform/scripts/lib/account-schema-owned-dirs.py` (add `FIXED_SEED`, add `_dir_holds_files`, rewrite the block-mutation part of `merge()`, extend the summary `print`)
26
+ - Test: `platform/scripts/__tests__/account-schema-owned-dirs.test.sh` (append new cases)
27
+
28
+ **Interfaces:**
29
+ - Consumes: existing `resolve(project_dir) -> [{dir, description, plugin}]`, `resolve_domain_buckets(project_dir) -> [{dir, label}]`, `_allowed_block(text) -> (entries, start, end)`, `_read_brand`, `_strip_region`.
30
+ - Produces: `FIXED_SEED` (frozenset of 23 str), `_dir_holds_files(account_dir, name) -> bool`, and a `merge()` whose summary line carries `removed=` and `keptPopulated=`.
31
+
32
+ - [ ] **Step 1: Write the failing tests**
33
+
34
+ Append to `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`, before the final `echo "PASS=$PASS FAIL=$FAIL"` line. Helper to inject a stray line inside the fenced block:
35
+
36
+ ```bash
37
+ # insert_stray <schema_path> <name>: add <name> as the first entry inside the
38
+ # allowed-top-level fence (simulates an account whose list carries a bucket no
39
+ # longer in the ontology).
40
+ insert_stray() {
41
+ awk -v n="$2" '{print} /^```allowed-top-level$/{print n}' "$1" > "$1.tmp" && mv "$1.tmp" "$1"
42
+ }
43
+
44
+ # --- prune: stale bucket, absent on disk -> removed from list + logged ---
45
+ A_PRUNE="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"
46
+ A_P="$(make_account "$A_PRUNE")"; export PROJECT_DIR="$A_PRUNE/platform"
47
+ insert_stray "$A_P/SCHEMA.md" "inbound-invoices"
48
+ OUT_P="$(merge_owned_dirs_into_schema "$A_P")"
49
+ ALLOWED_P="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_P/SCHEMA.md")"
50
+ assert_nogrep "inbound-invoices" "$ALLOWED_P" "prune-empty-removed-from-list"
51
+ assert_grep "removed=inbound-invoices" "$OUT_P" "prune-empty-logged"
52
+
53
+ # --- keep: stale bucket that holds files -> retained + keptPopulated ---
54
+ A_KEEP="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"
55
+ A_K="$(make_account "$A_KEEP")"; export PROJECT_DIR="$A_KEEP/platform"
56
+ insert_stray "$A_K/SCHEMA.md" "inbound-invoices"
57
+ mkdir -p "$A_K/inbound-invoices"; echo "stamp" > "$A_K/inbound-invoices/stamp_invoice.py"
58
+ OUT_K="$(merge_owned_dirs_into_schema "$A_K")"
59
+ ALLOWED_K="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_K/SCHEMA.md")"
60
+ assert_grep "inbound-invoices" "$ALLOWED_K" "keep-populated-retained"
61
+ assert_grep "keptPopulated=inbound-invoices" "$OUT_K" "keep-populated-logged"
62
+ assert_nogrep "removed=inbound-invoices" "$OUT_K" "keep-populated-not-in-removed"
63
+
64
+ # --- fixed-seed survival: empty base dir is never pruned ---
65
+ A_SEED="$(make_tree '[]')"; A_S="$(make_account "$A_SEED")"; export PROJECT_DIR="$A_SEED/platform"
66
+ OUT_S="$(merge_owned_dirs_into_schema "$A_S")"
67
+ ALLOWED_S="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_S/SCHEMA.md")"
68
+ for d in documents url-get .git output; do
69
+ assert_grep "$d" "$ALLOWED_S" "seed-survives-$d"
70
+ done
71
+ assert_grep "removed=none" "$OUT_S" "seed-nothing-removed"
72
+
73
+ # --- idempotency after a prune: second run byte-identical ---
74
+ B_P="$(shasum "$A_P/SCHEMA.md" | awk '{print $1}')"
75
+ merge_owned_dirs_into_schema "$A_P" >/dev/null
76
+ assert_eq "$(shasum "$A_P/SCHEMA.md" | awk '{print $1}')" "$B_P" "prune-idempotent"
77
+ ```
78
+
79
+ - [ ] **Step 2: Run the suite to verify the new cases fail**
80
+
81
+ Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
82
+ Expected: FAIL — `removed=`/`keptPopulated=` absent from output; `inbound-invoices` still in `ALLOWED_P`.
83
+
84
+ - [ ] **Step 3: Add `FIXED_SEED` and `_dir_holds_files`**
85
+
86
+ Insert after the `ONT_END` constant (line 25) region:
87
+
88
+ ```python
89
+ FIXED_SEED = frozenset({
90
+ "projects", "contacts", "documents", "url-get", "output", "generated",
91
+ "extracted", "uploads", "agents", "specialists", "sites", "public",
92
+ "cache", "secrets", "state", "logs", "tmp", "SCHEMA.md", "account.json",
93
+ "AGENTS.md", ".claude", ".git", ".quarantine",
94
+ })
95
+
96
+
97
+ def _dir_holds_files(account_dir, name):
98
+ """True iff account_dir/<name> carries file content: a non-empty regular
99
+ file, or a directory whose subtree contains at least one regular file.
100
+ Absent path, empty dir, or a dir tree with no files -> False (prunable)."""
101
+ path = os.path.join(account_dir, name)
102
+ if not os.path.exists(path):
103
+ return False
104
+ if os.path.isfile(path):
105
+ return os.path.getsize(path) > 0
106
+ for _root, _dirs, files in os.walk(path):
107
+ if files:
108
+ return True
109
+ return False
110
+ ```
111
+
112
+ - [ ] **Step 4: Rewrite the block-mutation section of `merge()`**
113
+
114
+ Replace the current block (lines 256-270, the `if entries is not None:` … `text = "\n".join(lines)` section) with:
115
+
116
+ ```python
117
+ removed = []
118
+ kept_populated = []
119
+ if entries is not None:
120
+ present = set(entries)
121
+ owned_dirs = {e["dir"] for e in owned}
122
+ domain_dirs = {b["dir"] for b in domain}
123
+ target = FIXED_SEED | owned_dirs | domain_dirs
124
+ # Reconcile existing entries: keep in-target; keep populated strays
125
+ # (logged); drop empty/absent strays (logged).
126
+ new_entries = []
127
+ for entry in entries:
128
+ if entry in target:
129
+ new_entries.append(entry)
130
+ elif _dir_holds_files(account_dir, entry):
131
+ new_entries.append(entry)
132
+ kept_populated.append(entry)
133
+ else:
134
+ removed.append(entry)
135
+ # Append newly-projected owned then domain dirs not already present.
136
+ insert = []
137
+ for e in owned:
138
+ if e["dir"] not in present and e["dir"] not in insert:
139
+ insert.append(e["dir"])
140
+ added.append(e["dir"])
141
+ for b in domain:
142
+ if b["dir"] not in present and b["dir"] not in insert:
143
+ insert.append(b["dir"])
144
+ added_domain.append(b["dir"])
145
+ new_entries.extend(insert)
146
+ lines = text.split("\n")
147
+ lines[start + 1:end] = new_entries
148
+ text = "\n".join(lines)
149
+ ```
150
+
151
+ - [ ] **Step 5: Extend the summary `print`**
152
+
153
+ Replace the final `print(...)` of `merge()` (lines 299-300) with:
154
+
155
+ ```python
156
+ print(f"brand={brand} added={','.join(added) if added else 'none'} "
157
+ f"domainBuckets={','.join(added_domain) if added_domain else 'none'} "
158
+ f"removed={','.join(removed) if removed else 'none'} "
159
+ f"keptPopulated={','.join(kept_populated) if kept_populated else 'none'}")
160
+ ```
161
+
162
+ Also update the early-return `print` (line 249, the no-SCHEMA.md branch) to keep the log shape uniform:
163
+
164
+ ```python
165
+ print(f"brand={brand} added=none domainBuckets=none removed=none keptPopulated=none")
166
+ ```
167
+
168
+ - [ ] **Step 6: Run the full suite to verify all pass**
169
+
170
+ Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
171
+ Expected: `PASS=<n> FAIL=0` (n = prior 38 + new assertions).
172
+
173
+ - [ ] **Step 7: Commit**
174
+
175
+ ```bash
176
+ git add platform/scripts/lib/account-schema-owned-dirs.py platform/scripts/__tests__/account-schema-owned-dirs.test.sh
177
+ git commit -m "feat(admin): reconcile account allowed-top-level to ontology, empty-only prune (Task 1887)"
178
+ ```
179
+
180
+ ---
181
+
182
+ ### Task 2: Document the projection + empty-only prune rule
183
+
184
+ **Files:**
185
+ - Modify: `.docs/data-portal-folder-index.md` (add a short section stating the reconcile rule)
186
+
187
+ - [ ] **Step 1: Add the rule to the living reference**
188
+
189
+ Append a section after the "Three details that fail silently" block explaining: `allowed-top-level` is reconciled each `merge()` to `fixed-seed ∪ plugin-owned ∪ ontology-buckets`; a stale entry (in the list, not in that union) is pruned only when its on-disk dir is empty or absent, and kept-and-logged (`keptPopulated=`) when it holds files, so the write-guard list tracks the ontology without ever orphaning live data into the 1877 quarantine reconcile.
190
+
191
+ - [ ] **Step 2: Commit**
192
+
193
+ ```bash
194
+ git add .docs/data-portal-folder-index.md
195
+ git commit -m "docs: account allowed-top-level projection + empty-only prune rule (Task 1887)"
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Self-Review
201
+
202
+ - **Spec coverage:** `merge()` reconcile (Task 1 Step 4), empty-only prune (Step 3 `_dir_holds_files` + Step 4), fixed-seed survival (Step 3 `FIXED_SEED` in `target`), logging `removed`/`keptPopulated` (Step 5), tests for prune-empty / keep-populated / seed-survival / idempotency (Step 1), `.docs` update (Task 2). New-ontology-bucket-add and collision cases stay covered by the existing untouched assertions.
203
+ - **Placeholder scan:** none — every step carries the literal code or command.
204
+ - **Type consistency:** `FIXED_SEED` frozenset of str; `_dir_holds_files(account_dir, name)->bool`; `removed`/`kept_populated` lists of str consumed only by the summary `print`. `owned`/`domain` element shapes match `resolve`/`resolve_domain_buckets` (`e["dir"]`, `b["dir"]`).
205
+ - **Regression boundary:** `lines[start+1:end] = new_entries` replaces only the fence body; the wholesale regeneration of the two descriptive regions (lines 272-296) is unchanged, so the em-dash/hyphen separator contract and portal exposure are untouched.
@@ -90,7 +90,10 @@ export declare function formatMessageTimestamp(iso: string): string | null;
90
90
  * Person givenName+familyName compose, Agent displayName/slug, Job
91
91
  * client+#jobId compose, Quote ref, InboundInvoice
92
92
  * supplier+#confirmationNumber compose, WhatsAppGroup clientName,
93
- * Message role+timestamp), then a generic
93
+ * Message role+timestamp, Property address/slug, Listing displayName/slug,
94
+ * Viewing date, Offer status+price, Visit purpose/status, PurchaseOrder
95
+ * poNumber, Invoice confirmationNumber, Risk/Finding/Hypothesis statement),
96
+ * then a generic
94
97
  * fallthrough across curated keys
95
98
  * (name, title, summary, givenName, subject, text) — :ConversationArchive
96
99
  * rows (Task 397) carry a stable `title` written by the writer so they pick
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAqItD,CAAA;AAED,eAAO,MAAM,qBAAqB,YAAY,CAAA;AAE9C,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAE7C,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAOzE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpD;AAiBD,eAAO,MAAM,qBAAqB,sFAQxB,CAAA;AAEV,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAA;AAEnE,eAAO,MAAM,oBAAoB,EAAE,cAAsB,CAAA;AAUzD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAE3D;AA8ED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIzD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAY7D;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,eAAe,EAAE,MAAM,CAAA;CACxB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,kBAAkB,CAU/E;AASD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC;AAKD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI7D;AAED,iEAAiE;AACjE,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIjE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CA2F1D;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CA0D3D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAqItD,CAAA;AAED,eAAO,MAAM,qBAAqB,YAAY,CAAA;AAE9C,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAE7C,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAOzE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpD;AAiBD,eAAO,MAAM,qBAAqB,sFAQxB,CAAA;AAEV,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAA;AAEnE,eAAO,MAAM,oBAAoB,EAAE,cAAsB,CAAA;AAUzD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAE3D;AA8ED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIzD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAY7D;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,eAAe,EAAE,MAAM,CAAA;CACxB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,kBAAkB,CAU/E;AASD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC;AAKD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI7D;AAED,iEAAiE;AACjE,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIjE;AA2BD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAqH1D;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAoF3D"}
@@ -356,13 +356,37 @@ function formatMessageTimestamp(iso) {
356
356
  return null;
357
357
  return m[1];
358
358
  }
359
+ // Estate-agent caption sources. :Property and :Listing carry no `name`; their
360
+ // identity lives in address/displayName with slug as the always-present natural
361
+ // key. Shared by pickShortLabel (truncated) and pickDisplayName (untruncated).
362
+ function estateAddress(props) {
363
+ const address = typeof props.address === 'string' ? props.address : '';
364
+ const slug = typeof props.slug === 'string' ? props.slug : '';
365
+ return address.length > 0 ? address : slug;
366
+ }
367
+ function estateListingName(props) {
368
+ const displayName = typeof props.displayName === 'string' ? props.displayName : '';
369
+ const slug = typeof props.slug === 'string' ? props.slug : '';
370
+ return displayName.length > 0 ? displayName : slug;
371
+ }
372
+ // :Offer carries no single human label; compose status with the offered price.
373
+ function offerCaption(props) {
374
+ const status = typeof props.status === 'string' ? props.status : '';
375
+ const price = typeof props.price === 'number' ? `£${props.price}`
376
+ : typeof props.price === 'string' && props.price.length > 0 ? `£${props.price}`
377
+ : '';
378
+ return [status, price].filter((s) => s.length > 0).join(' ');
379
+ }
359
380
  /**
360
381
  * 24-char canvas label for a node. Label precedence per label family
361
382
  * (ToolCall.toolName, WorkflowRun startedAt formatted, WorkflowStep.label,
362
383
  * Person givenName+familyName compose, Agent displayName/slug, Job
363
384
  * client+#jobId compose, Quote ref, InboundInvoice
364
385
  * supplier+#confirmationNumber compose, WhatsAppGroup clientName,
365
- * Message role+timestamp), then a generic
386
+ * Message role+timestamp, Property address/slug, Listing displayName/slug,
387
+ * Viewing date, Offer status+price, Visit purpose/status, PurchaseOrder
388
+ * poNumber, Invoice confirmationNumber, Risk/Finding/Hypothesis statement),
389
+ * then a generic
366
390
  * fallthrough across curated keys
367
391
  * (name, title, summary, givenName, subject, text) — :ConversationArchive
368
392
  * rows (Task 397) carry a stable `title` written by the writer so they pick
@@ -461,6 +485,48 @@ function pickShortLabel(node) {
461
485
  return `${roleShort} ${ts}`;
462
486
  }
463
487
  }
488
+ else if (primaryLabel === 'Property') {
489
+ const v = estateAddress(props);
490
+ if (v.length > 0)
491
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
492
+ }
493
+ else if (primaryLabel === 'Listing') {
494
+ const v = estateListingName(props);
495
+ if (v.length > 0)
496
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
497
+ }
498
+ else if (primaryLabel === 'Viewing') {
499
+ const v = typeof props.date === 'string' ? props.date : '';
500
+ if (v.length > 0)
501
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
502
+ }
503
+ else if (primaryLabel === 'Offer') {
504
+ const v = offerCaption(props);
505
+ if (v.length > 0)
506
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
507
+ }
508
+ else if (primaryLabel === 'Visit') {
509
+ const purpose = typeof props.purpose === 'string' ? props.purpose : '';
510
+ const status = typeof props.status === 'string' ? props.status : '';
511
+ const v = purpose.length > 0 ? purpose : status;
512
+ if (v.length > 0)
513
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
514
+ }
515
+ else if (primaryLabel === 'PurchaseOrder') {
516
+ const v = typeof props.poNumber === 'string' ? props.poNumber : '';
517
+ if (v.length > 0)
518
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
519
+ }
520
+ else if (primaryLabel === 'Invoice') {
521
+ const v = typeof props.confirmationNumber === 'string' ? props.confirmationNumber : '';
522
+ if (v.length > 0)
523
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
524
+ }
525
+ else if (primaryLabel === 'Risk' || primaryLabel === 'Finding' || primaryLabel === 'Hypothesis') {
526
+ const v = typeof props.statement === 'string' ? props.statement : '';
527
+ if (v.length > 0)
528
+ return v.length > 24 ? v.slice(0, 24) + '…' : v;
529
+ }
464
530
  for (const k of ['name', 'title', 'summary', 'givenName', 'subject', 'text']) {
465
531
  const v = props[k];
466
532
  if (typeof v === 'string' && v.length > 0) {
@@ -546,6 +612,48 @@ function pickDisplayName(node) {
546
612
  if (desc.length > 0)
547
613
  return desc;
548
614
  }
615
+ else if (primaryLabel === 'Property') {
616
+ const v = estateAddress(props);
617
+ if (v.length > 0)
618
+ return v;
619
+ }
620
+ else if (primaryLabel === 'Listing') {
621
+ const v = estateListingName(props);
622
+ if (v.length > 0)
623
+ return v;
624
+ }
625
+ else if (primaryLabel === 'Viewing') {
626
+ const v = typeof props.date === 'string' ? props.date : '';
627
+ if (v.length > 0)
628
+ return v;
629
+ }
630
+ else if (primaryLabel === 'Offer') {
631
+ const v = offerCaption(props);
632
+ if (v.length > 0)
633
+ return v;
634
+ }
635
+ else if (primaryLabel === 'Visit') {
636
+ const purpose = typeof props.purpose === 'string' ? props.purpose : '';
637
+ const status = typeof props.status === 'string' ? props.status : '';
638
+ const v = purpose.length > 0 ? purpose : status;
639
+ if (v.length > 0)
640
+ return v;
641
+ }
642
+ else if (primaryLabel === 'PurchaseOrder') {
643
+ const v = typeof props.poNumber === 'string' ? props.poNumber : '';
644
+ if (v.length > 0)
645
+ return v;
646
+ }
647
+ else if (primaryLabel === 'Invoice') {
648
+ const v = typeof props.confirmationNumber === 'string' ? props.confirmationNumber : '';
649
+ if (v.length > 0)
650
+ return v;
651
+ }
652
+ else if (primaryLabel === 'Risk' || primaryLabel === 'Finding' || primaryLabel === 'Hypothesis') {
653
+ const v = typeof props.statement === 'string' ? props.statement : '';
654
+ if (v.length > 0)
655
+ return v;
656
+ }
549
657
  for (const k of ['name', 'title', 'summary', 'givenName', 'subject', 'text']) {
550
658
  const v = props[k];
551
659
  if (typeof v === 'string' && v.length > 0)