@rubytech/create-maxy-code 0.1.441 → 0.1.443

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 (95) hide show
  1. package/dist/__tests__/converge-client-admins.test.js +50 -0
  2. package/dist/converge-client-admins.js +67 -0
  3. package/dist/index.js +107 -1
  4. package/package.json +1 -1
  5. package/payload/platform/docs/superpowers/plans/2026-07-13-account-schema-ontology-projection.md +547 -0
  6. package/payload/platform/docs/superpowers/plans/2026-07-14-graph-top-level-labels-ontology-single-source.md +458 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-13-account-schema-ontology-projection-design.md +178 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-14-graph-top-level-labels-ontology-single-source-design.md +119 -0
  9. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  10. package/payload/platform/plugins/admin/PLUGIN.md +7 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +19 -2
  12. package/payload/platform/plugins/admin/mcp/dist/index.js +126 -1
  13. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  14. package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.d.ts +30 -0
  15. package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.d.ts.map +1 -1
  16. package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.js +78 -0
  17. package/payload/platform/plugins/admin/mcp/dist/tools/account-lifecycle.js.map +1 -1
  18. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +37 -2
  19. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  20. package/payload/platform/plugins/docs/references/troubleshooting.md +35 -0
  21. package/payload/platform/plugins/memory/PLUGIN.md +2 -0
  22. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.js +12 -0
  23. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.js.map +1 -1
  24. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.d.ts +9 -1
  25. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.d.ts.map +1 -1
  26. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.js +19 -2
  27. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.js.map +1 -1
  28. package/payload/platform/plugins/memory/references/schema-estate-agent.md +8 -3
  29. package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js +37 -4
  30. package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js.map +1 -1
  31. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts +27 -3
  32. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts.map +1 -1
  33. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js +52 -11
  34. package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js.map +1 -1
  35. package/payload/platform/plugins/outlook/mcp/dist/index.js +1 -1
  36. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  37. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts +5 -0
  38. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts.map +1 -1
  39. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js +32 -0
  40. package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js.map +1 -1
  41. package/payload/platform/plugins/whatsapp/mcp/dist/index.js +20 -22
  42. package/payload/platform/plugins/whatsapp/mcp/dist/index.js.map +1 -1
  43. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +3 -1
  44. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +58 -0
  45. package/payload/platform/scripts/__tests__/provision-role-stamp.test.sh +6 -0
  46. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +143 -10
  47. package/payload/platform/scripts/lib/provision-account-dir.sh +5 -0
  48. package/payload/platform/scripts/logs-read.sh +24 -6
  49. package/payload/platform/scripts/logs-read.test.sh +42 -0
  50. package/payload/platform/scripts/name-glsmith-owner.sh +239 -0
  51. package/payload/platform/scripts/rss-sampler.sh +107 -0
  52. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  53. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  54. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  55. package/payload/server/public/assets/{AdminLoginScreens-CukwQufW.js → AdminLoginScreens-BJVwtpLH.js} +1 -1
  56. package/payload/server/public/assets/{AdminShell-BwEBH6tT.js → AdminShell-CMu8_Xwn.js} +1 -1
  57. package/payload/server/public/assets/{Checkbox-B629mOPp.js → Checkbox-CzD0ORqA.js} +1 -1
  58. package/payload/server/public/assets/{admin-CdFRCUUZ.js → admin-D0yk2LMq.js} +1 -1
  59. package/payload/server/public/assets/{browser-BWR59IkB.js → browser-CvfbgznO.js} +1 -1
  60. package/payload/server/public/assets/{calendar-kuTcxX1t.js → calendar-fw4WethJ.js} +1 -1
  61. package/payload/server/public/assets/chat-DhD-NES_.js +1 -0
  62. package/payload/server/public/assets/chevron-left-B3xSeEDR.js +1 -0
  63. package/payload/server/public/assets/data-CUnDzDox.js +1 -0
  64. package/payload/server/public/assets/{file-download-4mKJxUi-.js → file-download-Px4s0Bhp.js} +1 -1
  65. package/payload/server/public/assets/{graph-CboWM2_-.js → graph-DbT8nA6R.js} +1 -1
  66. package/payload/server/public/assets/{graph-labels-B8YAdo3Z.js → graph-labels-C1YIyLiA.js} +1 -1
  67. package/payload/server/public/assets/{maximize-2-CxstTgoK.js → maximize-2-BwphzI9S.js} +1 -1
  68. package/payload/server/public/assets/{operator-oSaB1mya.js → operator-zcIfeB14.js} +1 -1
  69. package/payload/server/public/assets/{page-DMeJHVfA.js → page-6fCnIb45.js} +2 -2
  70. package/payload/server/public/assets/page-CfQFAJia.js +1 -0
  71. package/payload/server/public/assets/{public-C54tl6V4.js → public-C0ehZCdE.js} +1 -1
  72. package/payload/server/public/assets/{rotate-ccw-COGQTi7n.js → rotate-ccw-C7BqkMec.js} +1 -1
  73. package/payload/server/public/assets/{tasks-B97VpwEJ.js → tasks-DlgiCafL.js} +1 -1
  74. package/payload/server/public/assets/{time-entry-format-C09rDHo9.js → time-entry-format-CyNYVDkq.js} +1 -1
  75. package/payload/server/public/assets/{useCopyFeedback-DHoYHiK9.js → useCopyFeedback-ByGUMN94.js} +1 -1
  76. package/payload/server/public/assets/useSubAccountSwitcher-1izQvPiD.css +1 -0
  77. package/payload/server/public/assets/useVoiceRecorder-XgpWvXpk.js +2 -0
  78. package/payload/server/public/browser.html +5 -5
  79. package/payload/server/public/calendar.html +6 -6
  80. package/payload/server/public/chat.html +12 -12
  81. package/payload/server/public/data.html +10 -10
  82. package/payload/server/public/graph.html +11 -11
  83. package/payload/server/public/index.html +13 -13
  84. package/payload/server/public/operator.html +14 -14
  85. package/payload/server/public/public.html +12 -12
  86. package/payload/server/public/tasks.html +5 -5
  87. package/payload/server/server.js +742 -321
  88. package/payload/platform/plugins/memory/references/schema-trades.md +0 -34
  89. package/payload/server/public/assets/chat-ejnq2rck.js +0 -1
  90. package/payload/server/public/assets/chevron-left-u4tjq7fI.js +0 -1
  91. package/payload/server/public/assets/data-dZbOyLdT.js +0 -1
  92. package/payload/server/public/assets/page-DEkI-Gl1.js +0 -1
  93. package/payload/server/public/assets/useSubAccountSwitcher-Dzmd7Aip.css +0 -1
  94. package/payload/server/public/assets/useVoiceRecorder-b7HzTFs0.js +0 -2
  95. /package/payload/server/public/assets/{useSubAccountSwitcher-DLJFISqf.js → useSubAccountSwitcher-s6WlRn52.js} +0 -0
@@ -0,0 +1,458 @@
1
+ # Graph top-level label allowlist ontology single-source — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make the `/graph` filter-popover chip allowlist (and the two other server routes that read the same set) derive from each vertical's `## Top-level node types` schema table instead of the hardcoded `FILTER_TOP_LEVEL_LABELS`, so a top-level label added to a vertical schema surfaces as a chip with no TypeScript edit, while preserving today's exact chip visibility.
6
+
7
+ **Architecture:** A new server-only module `platform/ui/server/lib/top-level-labels.ts` reads every `platform/plugins/memory/references/schema-*.md`, extracts the `Neo4j Label` column of each file's `## Top-level node types` table, and unions those with an explicit `STATIC_TOP_LEVEL_LABELS` const (base-schema infra plus verticals lacking a top-level section). The three server routes (`graph-labels-in-graph`, `graph-subgraph`, `graph-default-view`) call `getTopLevelLabelAllowlist()` instead of importing `FILTER_TOP_LEVEL_LABELS`. Count-gating in the cypher is unchanged.
8
+
9
+ **Tech Stack:** TypeScript (ES modules), Node `fs` (`readdirSync`/`readFileSync`), Vitest, Hono routes.
10
+
11
+ ## Global Constraints
12
+
13
+ - Derived union MUST deep-equal today's `FILTER_TOP_LEVEL_LABELS` (48 members) against the real references dir — the byte-identical acceptance baseline.
14
+ - No `schema:` namespace filter: every row of a `## Top-level node types` table contributes its `Neo4j Label`, so `WhatsAppGroup` (`cdm:Channel`) stays a chip.
15
+ - The `Neo4j Label` column is resolved by header name, never by index (construction table has 5 columns, estate-agent 4).
16
+ - The new module is server-only (reads files). `FILTER_TOP_LEVEL_LABELS` stays in `app/lib/graph-labels.ts` for the client `searchHelpers.ts`.
17
+ - `getTopLevelLabelAllowlist()` returns a `ReadonlySet<string>` so `.has()` and spread work at every call site.
18
+ - Observability line on computation: `op=top-level-labels source=ontology count=<n> files=<...> derived=<d> static=<s>`, written to stderr via `console.error`.
19
+ - No em-dashes in any shipped log string. No task numbers in operator-visible strings or log lines.
20
+ - Path resolution mirrors `server/index.ts`: `process.env.MAXY_PLATFORM_ROOT ?? join(__dirname, '..')`, then `plugins/memory/references`.
21
+
22
+ ---
23
+
24
+ ### Task 1: The derivation module and its regression test
25
+
26
+ **Files:**
27
+ - Create: `platform/ui/server/lib/top-level-labels.ts`
28
+ - Test: `platform/ui/server/lib/top-level-labels.test.ts`
29
+
30
+ **Interfaces:**
31
+ - Produces: `getTopLevelLabelAllowlist(opts?: { referencesDir?: string }): ReadonlySet<string>` and `STATIC_TOP_LEVEL_LABELS: ReadonlySet<string>`.
32
+ - Consumes: nothing from earlier tasks. Reads `FILTER_TOP_LEVEL_LABELS` from `../../app/lib/graph-labels` in the test only.
33
+
34
+ - [ ] **Step 1: Write the failing test**
35
+
36
+ ```ts
37
+ // platform/ui/server/lib/top-level-labels.test.ts
38
+ import { describe, it, expect } from 'vitest'
39
+ import { resolve } from 'node:path'
40
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
41
+ import { tmpdir } from 'node:os'
42
+ import { getTopLevelLabelAllowlist } from './top-level-labels'
43
+ import { FILTER_TOP_LEVEL_LABELS } from '../../app/lib/graph-labels'
44
+
45
+ // The real shipped references directory (four hops up from this test file:
46
+ // server/lib -> server -> ui -> platform -> plugins/memory/references).
47
+ const REAL_REFERENCES = resolve(
48
+ import.meta.dirname,
49
+ '../../../plugins/memory/references',
50
+ )
51
+
52
+ describe('getTopLevelLabelAllowlist', () => {
53
+ it('deep-equals FILTER_TOP_LEVEL_LABELS against the real references dir', () => {
54
+ const derived = getTopLevelLabelAllowlist({ referencesDir: REAL_REFERENCES })
55
+ expect([...derived].sort()).toEqual([...FILTER_TOP_LEVEL_LABELS].sort())
56
+ })
57
+
58
+ it('surfaces a new top-level row with no TypeScript edit', () => {
59
+ const dir = mkdtempSync(resolve(tmpdir(), 'tll-'))
60
+ writeFileSync(
61
+ resolve(dir, 'schema-widgets.md'),
62
+ [
63
+ '# Widgets',
64
+ '',
65
+ '## Top-level node types (operator-entry, natural key)',
66
+ '',
67
+ '| Entity | Neo4j Label | Schema.org Type | Required Properties |',
68
+ '|--------|-------------|-----------------|---------------------|',
69
+ '| Widget | `Widget` | `schema:Product` | `accountId`, `sku` |',
70
+ '',
71
+ ].join('\n'),
72
+ )
73
+ const derived = getTopLevelLabelAllowlist({ referencesDir: dir })
74
+ expect(derived.has('Widget')).toBe(true)
75
+ })
76
+
77
+ it('includes a cdm:-namespaced top-level row (no namespace filter)', () => {
78
+ const dir = mkdtempSync(resolve(tmpdir(), 'tll-'))
79
+ writeFileSync(
80
+ resolve(dir, 'schema-transport.md'),
81
+ [
82
+ '## Top-level node types',
83
+ '',
84
+ '| Entity | Neo4j Label | Schema.org Type | Required Properties |',
85
+ '|--------|-------------|-----------------|---------------------|',
86
+ '| Channel | `SomeChannel` | `cdm:Channel` | `accountId`, `id` |',
87
+ '',
88
+ ].join('\n'),
89
+ )
90
+ const derived = getTopLevelLabelAllowlist({ referencesDir: dir })
91
+ expect(derived.has('SomeChannel')).toBe(true)
92
+ })
93
+
94
+ it('ignores tables that are not under a Top-level node types heading', () => {
95
+ const dir = mkdtempSync(resolve(tmpdir(), 'tll-'))
96
+ writeFileSync(
97
+ resolve(dir, 'schema-child.md'),
98
+ [
99
+ '## Child node types (reached via parent neighbourhood)',
100
+ '',
101
+ '| Entity | Neo4j Label | Schema.org Type | Required Properties |',
102
+ '|--------|-------------|-----------------|---------------------|',
103
+ '| Line | `QuoteLine` | `schema:Thing` | `accountId`, `id` |',
104
+ '',
105
+ ].join('\n'),
106
+ )
107
+ const derived = getTopLevelLabelAllowlist({ referencesDir: dir })
108
+ // Only the STATIC base labels survive; QuoteLine is a child, not top-level.
109
+ expect(derived.has('QuoteLine')).toBe(false)
110
+ expect(derived.has('Person')).toBe(true)
111
+ })
112
+ })
113
+ ```
114
+
115
+ - [ ] **Step 2: Run test to verify it fails**
116
+
117
+ Run: `cd platform/ui && npx vitest run server/lib/top-level-labels.test.ts`
118
+ Expected: FAIL — `getTopLevelLabelAllowlist` is not exported / module not found.
119
+
120
+ - [ ] **Step 3: Write minimal implementation**
121
+
122
+ ```ts
123
+ // platform/ui/server/lib/top-level-labels.ts
124
+ /**
125
+ * Server-side derivation of the /graph top-level chip allowlist from the
126
+ * per-vertical ontology.
127
+ *
128
+ * The allowlist is the union of two sources:
129
+ * 1. The `Neo4j Label` column of every `## Top-level node types` table in
130
+ * `plugins/memory/references/schema-*.md`. Today only schema-construction
131
+ * and schema-estate-agent carry that section; more arrive as verticals
132
+ * gain one.
133
+ * 2. `STATIC_TOP_LEVEL_LABELS` — base-schema infra (schema-base.md has no
134
+ * top-level section; its Node Types table mixes in child types) plus any
135
+ * vertical whose schema has no top-level section yet.
136
+ *
137
+ * No namespace filter: every row contributes its label, so transport labels
138
+ * like WhatsAppGroup (cdm:Channel) stay chips. This differs from the account
139
+ * file-schema generator, which excludes non-schema: rows because a channel
140
+ * has no file bucket. A channel is still a legitimate operator-entry graph node.
141
+ */
142
+ import { readdirSync, readFileSync } from 'node:fs'
143
+ import { join, resolve } from 'node:path'
144
+
145
+ /**
146
+ * Top-level labels not (yet) sourced from a vertical `## Top-level node types`
147
+ * section. This set shrinks as verticals gain a top-level section; each such
148
+ * change moves its labels from here into the derived union.
149
+ */
150
+ export const STATIC_TOP_LEVEL_LABELS: ReadonlySet<string> = Object.freeze(
151
+ new Set([
152
+ // Base-schema infra (schema-base.md has no top-level section).
153
+ 'LocalBusiness',
154
+ 'Service',
155
+ 'PriceSpecification',
156
+ 'OpeningHoursSpecification',
157
+ 'Organization',
158
+ 'Person',
159
+ 'UserProfile',
160
+ 'Preference',
161
+ 'AdminUser',
162
+ 'AccessGrant',
163
+ 'KnowledgeDocument',
164
+ 'DigitalDocument',
165
+ 'CreativeWork',
166
+ 'Question',
167
+ 'FAQPage',
168
+ 'DefinedTerm',
169
+ 'Review',
170
+ 'ImageObject',
171
+ 'Invoice',
172
+ 'Task',
173
+ 'Project',
174
+ 'Event',
175
+ 'Workflow',
176
+ 'Email',
177
+ 'EmailAccount',
178
+ 'Agent',
179
+ // Knowledge-work vertical: schema-knowledge-work.md has no top-level
180
+ // section yet.
181
+ 'Objective',
182
+ 'KeyResult',
183
+ 'Decision',
184
+ 'Risk',
185
+ 'Source',
186
+ 'Finding',
187
+ 'Hypothesis',
188
+ ]),
189
+ ) as ReadonlySet<string>
190
+
191
+ /**
192
+ * Split a pipe-delimited markdown row into trimmed cells, honouring backtick
193
+ * code-spans so a `|` inside `` `A|B` `` does not split the row. Strips the
194
+ * optional leading/trailing pipes.
195
+ */
196
+ function splitPipeRow(row: string): string[] {
197
+ let trimmed = row.trim()
198
+ if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
199
+ if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
200
+ const cells: string[] = []
201
+ let inBacktick = false
202
+ let start = 0
203
+ for (let i = 0; i < trimmed.length; i++) {
204
+ const ch = trimmed[i]
205
+ if (ch === '`') inBacktick = !inBacktick
206
+ else if (ch === '|' && !inBacktick) {
207
+ cells.push(trimmed.slice(start, i).trim())
208
+ start = i + 1
209
+ }
210
+ }
211
+ cells.push(trimmed.slice(start).trim())
212
+ return cells
213
+ }
214
+
215
+ const stripBackticks = (s: string): string => s.replace(/`/g, '').trim()
216
+
217
+ /**
218
+ * Extract the `Neo4j Label` column of the first pipe-table beneath a
219
+ * `## Top-level node types` heading. Returns [] when the file has no such
220
+ * section. The heading is matched by case-insensitive prefix so the
221
+ * `(operator-entry, natural key)` suffix is tolerated; parsing stops at the
222
+ * next `## ` heading.
223
+ */
224
+ function parseTopLevelLabels(content: string): string[] {
225
+ const lines = content.split(/\r?\n/)
226
+ let i = 0
227
+ // Find the heading.
228
+ while (i < lines.length) {
229
+ const l = lines[i]
230
+ if (l.startsWith('## ') && l.toLowerCase().includes('top-level node types')) break
231
+ i++
232
+ }
233
+ if (i >= lines.length) return []
234
+ i++
235
+ // Find the header row of the first table before the next `## `.
236
+ for (; i < lines.length - 1; i++) {
237
+ const line = lines[i]
238
+ if (line.startsWith('## ')) return []
239
+ if (!line.includes('|')) continue
240
+ const sep = lines[i + 1]
241
+ if (!/^\s*\|?[\s:|-]+\|?\s*$/.test(sep) || !sep.includes('-')) continue
242
+ const headers = splitPipeRow(line)
243
+ const labelCol = headers.findIndex((h) => h.toLowerCase() === 'neo4j label')
244
+ if (labelCol === -1) return []
245
+ const out: string[] = []
246
+ let j = i + 2
247
+ for (; j < lines.length; j++) {
248
+ const row = lines[j]
249
+ if (!row.trim() || !row.includes('|')) break
250
+ const cells = splitPipeRow(row)
251
+ const label = stripBackticks(cells[labelCol] ?? '')
252
+ if (label) out.push(label)
253
+ }
254
+ return out
255
+ }
256
+ return []
257
+ }
258
+
259
+ function resolveReferencesDir(): string {
260
+ // Production: MAXY_PLATFORM_ROOT is `<install>/platform`, and the schema
261
+ // references live at `<platform>/plugins/memory/references` (same root
262
+ // schema-loader.ts resolves brand.json against).
263
+ if (process.env.MAXY_PLATFORM_ROOT) {
264
+ return join(process.env.MAXY_PLATFORM_ROOT, 'plugins', 'memory', 'references')
265
+ }
266
+ // Dev fallback (env unset, unbundled): three hops up from this source file,
267
+ // server/lib -> server -> ui -> platform, then plugins/memory/references.
268
+ return resolve(import.meta.dirname, '../../../plugins/memory/references')
269
+ }
270
+
271
+ let cached: ReadonlySet<string> | null = null
272
+
273
+ /**
274
+ * The /graph top-level chip allowlist, derived from the ontology. Cached at
275
+ * module scope because schema files are static per install. An injected
276
+ * `referencesDir` (tests) bypasses the cache and does not populate it.
277
+ */
278
+ export function getTopLevelLabelAllowlist(
279
+ opts: { referencesDir?: string } = {},
280
+ ): ReadonlySet<string> {
281
+ if (!opts.referencesDir && cached) return cached
282
+
283
+ const referencesDir = opts.referencesDir ?? resolveReferencesDir()
284
+ const fileNames = readdirSync(referencesDir)
285
+ .filter((f) => f.startsWith('schema-') && f.endsWith('.md'))
286
+ .sort()
287
+
288
+ const derived = new Set<string>()
289
+ const contributingFiles: string[] = []
290
+ for (const fileName of fileNames) {
291
+ const content = readFileSync(join(referencesDir, fileName), 'utf-8')
292
+ const labels = parseTopLevelLabels(content)
293
+ if (labels.length > 0) contributingFiles.push(fileName)
294
+ for (const label of labels) derived.add(label)
295
+ }
296
+
297
+ const derivedCount = derived.size
298
+ for (const label of STATIC_TOP_LEVEL_LABELS) derived.add(label)
299
+
300
+ const result = Object.freeze(derived) as ReadonlySet<string>
301
+ process.stderr.write(
302
+ `[graph-page] op=top-level-labels source=ontology count=${derived.size} ` +
303
+ `files=${contributingFiles.join('|') || 'none'} derived=${derivedCount} ` +
304
+ `static=${STATIC_TOP_LEVEL_LABELS.size}\n`,
305
+ )
306
+
307
+ if (!opts.referencesDir) cached = result
308
+ return result
309
+ }
310
+ ```
311
+
312
+ - [ ] **Step 4: Run test to verify it passes**
313
+
314
+ Run: `cd platform/ui && npx vitest run server/lib/top-level-labels.test.ts`
315
+ Expected: PASS — all four tests green. If the deep-equal test fails, the diff names the drifted label(s); reconcile `STATIC_TOP_LEVEL_LABELS` against `FILTER_TOP_LEVEL_LABELS` (do not weaken the assertion).
316
+
317
+ - [ ] **Step 5: Commit**
318
+
319
+ ```bash
320
+ git add platform/ui/server/lib/top-level-labels.ts platform/ui/server/lib/top-level-labels.test.ts
321
+ git commit -m "feat: derive /graph top-level chip allowlist from vertical ontology (1624)"
322
+ ```
323
+
324
+ ---
325
+
326
+ ### Task 2: Point the three server routes at the derived allowlist
327
+
328
+ **Files:**
329
+ - Modify: `platform/ui/server/routes/admin/graph-labels-in-graph.ts`
330
+ - Modify: `platform/ui/server/routes/admin/graph-subgraph.ts`
331
+ - Modify: `platform/ui/server/routes/admin/graph-default-view.ts`
332
+
333
+ **Interfaces:**
334
+ - Consumes: `getTopLevelLabelAllowlist(): ReadonlySet<string>` from Task 1.
335
+ - Produces: nothing new; behaviour of the three routes is unchanged because the derived set deep-equals `FILTER_TOP_LEVEL_LABELS`.
336
+
337
+ - [ ] **Step 1: Confirm the existing route tests pass before the swap (baseline)**
338
+
339
+ Run: `cd platform/ui && npx vitest run app/graph/__tests__/labels-in-graph.test.ts server/routes/__tests__/graph-default-topLevel.test.ts`
340
+ Expected: PASS. This is the regression baseline the swap must preserve.
341
+
342
+ - [ ] **Step 2: Swap the popover route**
343
+
344
+ In `graph-labels-in-graph.ts`, change the import block
345
+
346
+ ```ts
347
+ import {
348
+ FILTER_TOP_LEVEL_LABELS,
349
+ AGENT_ACTION_LABELS,
350
+ } from '../../../app/lib/graph-labels'
351
+ ```
352
+
353
+ to
354
+
355
+ ```ts
356
+ import { AGENT_ACTION_LABELS } from '../../../app/lib/graph-labels'
357
+ import { getTopLevelLabelAllowlist } from '../../lib/top-level-labels'
358
+ ```
359
+
360
+ and change the allowlist construction (currently `new Set<string>(FILTER_TOP_LEVEL_LABELS)`)
361
+
362
+ ```ts
363
+ const allowedSet = new Set<string>(getTopLevelLabelAllowlist())
364
+ if (includeAgentActions) {
365
+ for (const label of AGENT_ACTION_LABELS) allowedSet.add(label)
366
+ }
367
+ ```
368
+
369
+ - [ ] **Step 3: Swap the default-view fetch route**
370
+
371
+ In `graph-subgraph.ts`, remove `FILTER_TOP_LEVEL_LABELS` from the `../../../app/lib/graph-labels` import (keep `HIDDEN_BY_DEFAULT_LABELS` and any others it imports), add `import { getTopLevelLabelAllowlist } from '../../lib/top-level-labels'`, and change line ~280
372
+
373
+ ```ts
374
+ const labels = [...getTopLevelLabelAllowlist()].filter((l) => !HIDDEN_BY_DEFAULT_LABELS.has(l))
375
+ ```
376
+
377
+ - [ ] **Step 4: Swap the eligibility route**
378
+
379
+ In `graph-default-view.ts`, replace
380
+
381
+ ```ts
382
+ import { FILTER_TOP_LEVEL_LABELS } from '../../../app/lib/graph-labels'
383
+ ```
384
+
385
+ with
386
+
387
+ ```ts
388
+ import { getTopLevelLabelAllowlist } from '../../lib/top-level-labels'
389
+ ```
390
+
391
+ and change the eligibility check (line ~158)
392
+
393
+ ```ts
394
+ if (!getTopLevelLabelAllowlist().has(lbl)) {
395
+ ```
396
+
397
+ Update the adjacent `reason` string to drop the parenthetical `(FILTER_TOP_LEVEL_LABELS)` naming so no internal set name leaks; keep the operator-facing text: `label "${lbl}" is not eligible for default view — outside the chip allowlist`.
398
+
399
+ - [ ] **Step 5: Run the route tests to verify no behaviour change**
400
+
401
+ Run: `cd platform/ui && npx vitest run app/graph/__tests__/labels-in-graph.test.ts server/routes/__tests__/graph-default-topLevel.test.ts`
402
+ Expected: PASS — the derived set equals `FILTER_TOP_LEVEL_LABELS`, so every assertion still holds.
403
+
404
+ - [ ] **Step 6: Typecheck the UI package**
405
+
406
+ Run: `cd platform/ui && npx tsc --noEmit`
407
+ Expected: no errors (no unused-import errors for the removed `FILTER_TOP_LEVEL_LABELS`).
408
+
409
+ - [ ] **Step 7: Commit**
410
+
411
+ ```bash
412
+ git add platform/ui/server/routes/admin/graph-labels-in-graph.ts platform/ui/server/routes/admin/graph-subgraph.ts platform/ui/server/routes/admin/graph-default-view.ts
413
+ git commit -m "refactor: three /graph server routes read the ontology-derived allowlist (1624)"
414
+ ```
415
+
416
+ ---
417
+
418
+ ### Task 3: Update the graph-labels doc comment and file the client follow-up
419
+
420
+ **Files:**
421
+ - Modify: `platform/ui/app/lib/graph-labels.ts` (comment only — the set stays for the client)
422
+ - Create: `.tasks/NNNN-graph-searchhelpers-derive-top-level-from-ontology.md` (number assigned at land time from LANES)
423
+
424
+ **Interfaces:**
425
+ - Consumes: nothing. Documentation and task-tracking only.
426
+
427
+ - [ ] **Step 1: Annotate `FILTER_TOP_LEVEL_LABELS` as the client-only mirror**
428
+
429
+ Add a sentence to the `FILTER_TOP_LEVEL_LABELS` doc block in `graph-labels.ts` recording that the server routes now derive this set from the vertical ontology (`server/lib/top-level-labels.ts`), and that this constant remains only for the client `searchHelpers.ts`, which cannot read files. Do not change the set members.
430
+
431
+ - [ ] **Step 2: Write the follow-up task file**
432
+
433
+ Create the task file describing: plumb the ontology-derived allowlist to the client `app/data/searchHelpers.ts` so its `/data` search chip pre-filter stops reading the static `FILTER_TOP_LEVEL_LABELS`, closing the last second source. In scope: deliver the derived set to the client (via the `graph-labels-in-graph` response the page already fetches, or an equivalent). Out of scope: the server routes (done here). Include Testing and Observability sections per the `.tasks` format.
434
+
435
+ - [ ] **Step 3: Commit**
436
+
437
+ ```bash
438
+ git add platform/ui/app/lib/graph-labels.ts .tasks/NNNN-graph-searchhelpers-derive-top-level-from-ontology.md
439
+ git commit -m "docs: mark FILTER_TOP_LEVEL_LABELS client-only; file searchHelpers follow-up (1624)"
440
+ ```
441
+
442
+ ---
443
+
444
+ ## Self-Review
445
+
446
+ **Spec coverage:**
447
+ - New module + derivation → Task 1. ✅
448
+ - Three server routes switch → Task 2. ✅
449
+ - Byte-identical baseline → Task 1 Step 1 deep-equal test + Task 2 route tests. ✅
450
+ - No namespace filter (WhatsAppGroup) → Task 1 Step 1 cdm test + the real-dir deep-equal (which contains WhatsAppGroup). ✅
451
+ - Base list explicit → `STATIC_TOP_LEVEL_LABELS` in Task 1. ✅
452
+ - Observability line → Task 1 Step 3 `process.stderr.write`. ✅
453
+ - Client follow-up filed → Task 3. ✅
454
+ - Count-gating unchanged → cypher untouched in Task 2; asserted by the pre-existing route tests. ✅
455
+
456
+ **Placeholder scan:** The only deferred value is the follow-up task number (`NNNN`), assigned from LANES at archive time per sprint protocol — not a code placeholder.
457
+
458
+ **Type consistency:** `getTopLevelLabelAllowlist(opts?: { referencesDir?: string }): ReadonlySet<string>` is used identically in Task 1 (definition), Task 1 test, and all three Task 2 call sites. `STATIC_TOP_LEVEL_LABELS` is a `ReadonlySet<string>`. Call sites use `.has()` and spread, both valid on `ReadonlySet`.
@@ -0,0 +1,178 @@
1
+ # Account file-schema as a real projection of the domain graph ontology — design
2
+
3
+ Source task: `.tasks/1622-account-file-schema-does-not-mirror-the-domain-graph-ontology.md`.
4
+ Prior design (the mechanism this extends): `2026-06-23-account-filesystem-schema-design.md`.
5
+
6
+ ## Problem
7
+
8
+ The account data directory is specified to be a projection of the graph ontology,
9
+ but the allowed top-level set is a hardcoded generic base
10
+ (`platform/templates/account-schema/SCHEMA.md`: `projects`, `contacts`,
11
+ `documents` plus tool-owned dirs). Domain entities that the businessType ontology
12
+ declares top-level are never added. On SiteDesk the ontology
13
+ (`platform/plugins/memory/references/schema-construction.md`) makes `:Job` the hub
14
+ with `:Customer`/`:Site`/`:Quote`/`:Visit`/`:Asset` as first-class operator-entry
15
+ node types, and the sidebar's SiteDesk primary container is `:Job`, yet the
16
+ account `SCHEMA.md` lists no `jobs` bucket. So a produced quote is filed inside the
17
+ pricing plugin's private `quoting/jobs/<jobId>/` workspace, disconnected from the
18
+ `:Job`/`:Quote` graph nodes and invisible to the Jobs view.
19
+
20
+ A second, internal inconsistency exists inside the sitedesk-job plugin: the
21
+ `valuation` skill's `references/job-folder.md` already documents the job folder as
22
+ the top-level `$ACCOUNT_DIR/jobs/{jobId}/`, a path the file-schema does not permit,
23
+ so valuation's own filing is admissible only by accident. Making `jobs/` a real
24
+ derived bucket fixes both the quotation gap and this latent valuation gap.
25
+
26
+ ## The single source (resolves the task's "Depends on")
27
+
28
+ `platform/plugins/memory/mcp/src/lib/schema-loader.ts` already machine-parses the
29
+ schema markdown entity tables (each recognised by a `Neo4j Label` +
30
+ `Required Properties` header pair). `brand.json#vertical` already names which
31
+ `schema-<vertical>.md` applies (SiteDesk = `schema-construction`). Therefore
32
+ `schema-<vertical>.md` is the single authoritative ontology, and the file-schema
33
+ generator becomes a second reader of the same file. The graph loader itself does
34
+ not change: it already reads these files. "The ontology" is one list, read by both
35
+ the loader (for validation) and the generator (for top-level bucket derivation).
36
+
37
+ `graph-labels.ts` `FILTER_TOP_LEVEL_LABELS` (the `/graph` UI chip allowlist) stays
38
+ a separate concern this sprint. Single-sourcing it from the same per-vertical
39
+ source is filed as a follow-up, not built here.
40
+
41
+ ## What ships
42
+
43
+ ### A. File-schema derives domain buckets from the vertical ontology
44
+
45
+ **Convention for "top-level".** A label is a derived operator-entity bucket iff it
46
+ appears as a row in the entity table under the heading
47
+ `## Top-level node types (operator-entry, natural key)` in `schema-<vertical>.md`
48
+ **and** its `Schema.org Type` column is a `schema:`-namespaced type. The
49
+ `schema:`-namespace test deterministically excludes transport labels
50
+ (`WhatsAppGroup` carries `cdm:Channel`) with no hardcoded skip-list. A vertical
51
+ whose schema file carries no such heading contributes no domain buckets (generic
52
+ base only) and is surfaced by the reconcile check (below).
53
+
54
+ **Label to directory name.** kebab-case-plural of the label: camelCase word
55
+ boundaries split on case, joined with `-`, lowercased, with a trailing `s`
56
+ appended (or `y` becomes `ies`). Examples: `Job` to `jobs`, `Customer` to
57
+ `customers`, `Quote` to `quotes`, `Asset` to `assets`, `Visit` to `visits`,
58
+ `Part` to `parts`, `PurchaseOrder` to `purchase-orders`, `PpmContract` to
59
+ `ppm-contracts`, `InboundInvoice` to `inbound-invoices`. One collision
60
+ special-case: `Site` maps to `customer-sites`, so the tool-owned `sites/`
61
+ published-tree bucket is untouched. The special-case is a single explicit entry in
62
+ a `LABEL_DIR_OVERRIDES` map keyed by label; every other label uses the mechanical
63
+ rule.
64
+
65
+ **Generator.** `platform/scripts/lib/account-schema-owned-dirs.py` gains a
66
+ `resolve_domain_buckets(project_dir)` beside the existing `resolve()` (plugin
67
+ owned-dirs). It reads `brand.json#vertical`, opens
68
+ `<project_dir>/plugins/memory/references/<vertical>.md`, extracts the top-level
69
+ labels per the convention above, maps each to a dir, and returns
70
+ `[{dir, label}, ...]` in table order. `merge()` unions these dirs into the
71
+ `allowed-top-level` fenced block (same insertion path as owned-dirs) and writes a
72
+ documented region between `<!-- ontology-buckets:start -->` and
73
+ `<!-- ontology-buckets:end -->` markers, listing each bucket and its source label.
74
+ The region is regenerated wholesale on every merge (idempotent), exactly like the
75
+ existing plugin-owned region.
76
+
77
+ **Provision.** No new call site. `merge_owned_dirs_into_schema` (in
78
+ `account-schema-owned-dirs.sh`, already called by `provision-account-dir.sh` and
79
+ `setup-account.sh`) now emits domain buckets as well as owned-dirs. Empty domain
80
+ directories are **not** pre-created on provision. A fresh account has no jobs yet;
81
+ the directory is created on first write, the same as any tool-owned dir. Only the
82
+ allowed set and the `SCHEMA.md` region change at provision time.
83
+
84
+ **Guard.** `platform/plugins/admin/hooks/fs-schema-guard.sh` needs no logic change.
85
+ It already reads the account's `SCHEMA.md` allowed block and admits any first
86
+ segment in that set at any depth except the three flat operator-data buckets
87
+ (`projects`, `contacts`, `documents`). A domain bucket such as `jobs/` is therefore
88
+ admitted at any depth, which is correct for the `jobs/<jobId>/Quotations/...`
89
+ subtree the ontology's own filesystem projection describes. The derived buckets
90
+ reach the guard through the regenerated allowed block.
91
+
92
+ ### B. Quotation reifies the Job/Quote/QuoteLine chain and files under the Job
93
+
94
+ With `jobs/` a real bucket, the quotation apply-path is brought to parity with
95
+ `valuation`'s job-setup (which already creates/updates `:Job` and `:Quote` and
96
+ seeds `:QuoteLine`s on the quote). After the engine prices the job and
97
+ `quote-render`'s fail-closed gates pass, the apply-path:
98
+
99
+ 1. **Reifies** `:Job-[:HAS_QUOTE]->:Quote-[:HAS_LINE]->:QuoteLine`, idempotent on
100
+ the natural keys the construction ontology defines (`:Job` on
101
+ `(accountId, jobId)`, `:Quote` on `(accountId, ref)`, `:QuoteLine` on
102
+ `(accountId, jobId, trade, description)`), via the same graph-write path
103
+ `valuation` uses. The exact write surface (memory tools vs the
104
+ `database-operator` specialist) is pinned in the plan by reading how
105
+ `valuation`'s job-setup writes today, so the two paths stay identical.
106
+ 2. **Files** the produced quote documents under the Job bucket
107
+ (`jobs/<jobId>/Quotations/...`, the operator-facing location valuation's
108
+ `job-folder.md` already documents), not orphaned in `quoting/jobs/<jobId>/`.
109
+ The `quoting/` engine workspace (`engine.mjs`, `method.json`, `render.mjs`, and
110
+ per-job figures) stays tool-owned. What changes is `quote-render`'s
111
+ filing-scope for the operator-facing result and the graph binding.
112
+ 3. **Emits** `[quote-render] op=quote-reify jobId=<id> quoteRef=<ref> lines=<n>` on
113
+ the graph write, a post-condition breadcrumb. A quote that renders documents but
114
+ writes no `:Quote` node is then immediately visible in the log.
115
+
116
+ The schema-free pricing-method capture (item catalogue, rates, roll-up) is
117
+ untouched. Only the output's placement and its entity binding are in scope.
118
+
119
+ ### C. estate-agent schema reorganisation (proves derivation is not hardcoded)
120
+
121
+ `schema-estate-agent.md` currently holds one mixed `## Additional Node Types` table
122
+ (hub `Property` plus its children `Listing`, `ImageObject`, `Viewing`, `Applicant`,
123
+ `Offer`). It is reorganised into a `## Top-level node types (operator-entry,
124
+ natural key)` table and a `## Child node types` table, with no row duplication, so
125
+ `schema-loader` sees the same union of labels it sees today (it already parses
126
+ construction's two-table shape). Top-level for estate-agent: `Property` (the
127
+ obligatory hub) plus the operator-entry entities the vertical's prose names
128
+ (`Listing`, `Viewing`, `Offer`). `ImageObject` is a child of `Listing` (reached
129
+ through its parent, as construction places `QuoteLine`); `Applicant` is a Person
130
+ extension (base `:Person` plus applicant properties, the same additional-label
131
+ pattern construction uses for `Engineer`/`Supplier`), so neither is a top-level
132
+ bucket. The exact top-level/child split for estate-agent is finalised during
133
+ implementation by reading the whole file, but the two `schema:`-namespaced sets
134
+ above are the intent. The result: a construction account derives `jobs`/`customers`/...
135
+ and an estate-agent account derives `properties`/`listings`/`viewings`/`offers`
136
+ from the same generator, proving the set is derived, not hardcoded.
137
+
138
+ ## Testing
139
+
140
+ - `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`: for a
141
+ construction brand, the merged allowed set contains `jobs`, `customers`,
142
+ `customer-sites`, `assets`, `visits`, `quotes` (and the rest), and does **not**
143
+ collide with the tool-owned `sites`. For an estate-agent brand it contains
144
+ `properties`, `listings`, `viewings`, `offers` and none of SiteDesk's buckets. A
145
+ vertical with no top-level heading yields no domain buckets (generic base
146
+ intact). `reconcile` reports every top-level label and whether its bucket is
147
+ present.
148
+ - `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`: a write under
149
+ `jobs/<jobId>/Quotations/q.pdf` is admitted (domain bucket, any depth); an
150
+ undeclared top-level segment is still blocked; the three flat buckets keep their
151
+ over-deep block.
152
+ - Cardinal repro (device-side, per the Pi-signal verification step): producing a
153
+ quote files the documents under the Job and the graph shows
154
+ `:Job-[:HAS_QUOTE]->:Quote-[:HAS_LINE]->:QuoteLine`; the quote appears in the
155
+ sidebar Jobs view. What is unit-testable locally is the derivation, the guard
156
+ admission, and the presence of the reify call and its breadcrumb.
157
+
158
+ ## Observability
159
+
160
+ - `account-schema-owned-dirs.py reconcile` is extended into a standing check that
161
+ reports, per account, every top-level ontology entity **missing** a file-schema
162
+ bucket, so a re-divergence surfaces without waiting for a mis-filed artifact.
163
+ - `[quote-render] op=quote-reify jobId= quoteRef= lines=` on quote production
164
+ confirms the graph write occurred.
165
+
166
+ ## Out of scope (each a filed follow-up, no prose-only deferral)
167
+
168
+ - Normalising the remaining vertical schema files (`schema-retail`,
169
+ `schema-logistics`, `schema-hospitality`, `schema-food-beverage`,
170
+ `schema-professional-services`, `schema-trades`, `schema-creator`,
171
+ `schema-knowledge-work`) to carry the top-level heading. Until done they fall
172
+ back to the generic base and the reconcile check flags them. New task.
173
+ - Single-sourcing `graph-labels.ts` `FILTER_TOP_LEVEL_LABELS` from the same
174
+ per-vertical ontology source. New task.
175
+ - Backfilling existing `quoting/jobs/<jobId>/` figures folders for
176
+ already-produced quotes (the source task already defers this).
177
+ - Any change to the graph schema, the `schema-loader` validation path, or the
178
+ `fs-schema-guard` decision logic beyond consuming the derived set.