@rubytech/create-maxy-code 0.1.485 → 0.1.487

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 (105) 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 +5 -3
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +22 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +23 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +43 -0
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +54 -5
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  37. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  38. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  52. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  53. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  55. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  56. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  57. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  58. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  59. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  60. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  61. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  62. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  63. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  64. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  65. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  66. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  67. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  68. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts +2 -0
  69. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts.map +1 -0
  70. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js +12 -0
  71. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js.map +1 -0
  72. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts +8 -0
  73. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts.map +1 -0
  74. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js +12 -0
  75. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js.map +1 -0
  76. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +12 -2
  77. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
  78. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  79. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  80. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  81. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  82. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  83. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +315 -0
  84. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  85. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  86. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  87. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  89. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  91. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  92. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  93. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  94. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  95. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  96. package/payload/server/maxy-edge.js +1 -1
  97. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  98. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  99. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  100. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  101. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  102. package/payload/server/public/data.html +3 -3
  103. package/payload/server/public/graph.html +2 -2
  104. package/payload/server/public/index.html +3 -3
  105. package/payload/server/server.js +231 -103
@@ -110,31 +110,46 @@ merge_owned_dirs_into_schema "$A_TB" >/dev/null
110
110
  ALLOWED_TB="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_TB/SCHEMA.md")"
111
111
  assert_grep "ledger" "$ALLOWED_TB" "shapeb-allowlist-has-ledger"
112
112
 
113
- # --- construction vertical: domain buckets derived, no sites-collision ---
113
+ # --- construction vertical: root buckets derived, job-owned entities nested ---
114
114
  T_C="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"; A_C="$(make_account "$T_C")"
115
115
  export PROJECT_DIR="$T_C/platform"
116
116
  OUT_C="$(merge_owned_dirs_into_schema "$A_C")"
117
117
  # Match a non-empty derived list, not the bare key (which prints even for zero).
118
118
  assert_grep "domainBuckets=jobs" "$OUT_C" "con-domain-log"
119
+ # Summary reports the full root set and the job-owned entities with their parent.
120
+ assert_grep "roots=jobs,customers,customer-sites,assets,parts,ppm-contracts" "$OUT_C" "con-roots-log"
121
+ assert_grep "nested=Visit<-Job,Quote<-Job,PurchaseOrder<-Job" "$OUT_C" "con-nested-log"
119
122
  ALLOWED_C="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_C/SCHEMA.md")"
120
- for d in jobs customers customer-sites assets visits quotes purchase-orders ppm-contracts; do
123
+ # Root entities (blank Owner) get a top-level bucket.
124
+ for d in jobs customers customer-sites assets parts ppm-contracts; do
121
125
  assert_grep "$d" "$ALLOWED_C" "con-allow-$d"
122
126
  done
127
+ # Job-owned entities (Owner=Job) do NOT get a root bucket.
128
+ for d in visits quotes purchase-orders; do
129
+ assert_nogrep "$d" "$ALLOWED_C" "con-noroot-$d"
130
+ done
123
131
  assert_nogrep "whatsapp-groups" "$ALLOWED_C" "con-no-transport-bucket"
124
132
  assert_grep "customer-sites" "$ALLOWED_C" "con-site-renamed"
125
- assert_grep "Domain entity buckets" "$(cat "$A_C/SCHEMA.md")" "con-region"
133
+ SCHEMA_C="$(cat "$A_C/SCHEMA.md")"
134
+ assert_grep "Domain entity buckets" "$SCHEMA_C" "con-region"
135
+ # The descriptive region tells the agent where a job-owned entity's records live.
136
+ assert_grep '`Quote` records live under `jobs/` (owned by `Job`), not a root bucket.' "$SCHEMA_C" "con-nested-line"
126
137
  B_C="$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')"
127
138
  merge_owned_dirs_into_schema "$A_C" >/dev/null
128
139
  assert_eq "$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')" "$B_C" "con-idempotent"
129
140
 
130
- # --- estate-agent vertical: its own buckets, not SiteDesk's ---
141
+ # --- estate-agent vertical: root buckets only, listing-owned entities nested ---
131
142
  T_E="$(make_vertical_tree '["real-agent"]' 'schema-estate-agent')"; A_E="$(make_account "$T_E")"
132
143
  export PROJECT_DIR="$T_E/platform"
133
144
  merge_owned_dirs_into_schema "$A_E" >/dev/null
134
145
  ALLOWED_E="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_E/SCHEMA.md")"
135
- for d in properties listings viewings offers; do
146
+ # Root entities get a bucket; Listing-owned Viewing/Offer do not.
147
+ for d in properties listings; do
136
148
  assert_grep "$d" "$ALLOWED_E" "ea-allow-$d"
137
149
  done
150
+ for d in viewings offers; do
151
+ assert_nogrep "$d" "$ALLOWED_E" "ea-noroot-$d"
152
+ done
138
153
  assert_nogrep "jobs" "$ALLOWED_E" "ea-no-sitedesk-jobs"
139
154
 
140
155
  # --- no vertical declared: generic base only ---
@@ -145,14 +160,68 @@ ALLOWED_N="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_N/SCHEM
145
160
  assert_nogrep "jobs" "$ALLOWED_N" "novert-no-domain"
146
161
  assert_nogrep "Domain entity buckets" "$(cat "$A_N/SCHEMA.md")" "novert-no-region"
147
162
 
148
- # --- reconcile reports each ontology entity present/absent ---
163
+ # --- reconcile: root entity present/absent, job-owned entity never at root ---
149
164
  A_RCD="$(make_account "$T_C")"
150
165
  export PROJECT_DIR="$T_C/platform"
151
166
  RCD_BEFORE="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RCD")"
152
167
  assert_grep "ontologyEntity=Job bucket=jobs present=false" "$RCD_BEFORE" "rcd-job-absent"
168
+ # A job-owned entity is reported as not-expected-at-root, not as a plain bucket.
169
+ assert_grep "ontologyEntity=Quote owner=Job bucket=quotes expectedAtRoot=false stray=false" "$RCD_BEFORE" "rcd-quote-nested"
153
170
  merge_owned_dirs_into_schema "$A_RCD" >/dev/null
154
171
  RCD_AFTER="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RCD")"
155
172
  assert_grep "ontologyEntity=Job bucket=jobs present=true" "$RCD_AFTER" "rcd-job-present"
173
+ # After a clean merge the job-owned bucket is still absent from root (no stray).
174
+ assert_grep "ontologyEntity=Quote owner=Job bucket=quotes expectedAtRoot=false stray=false" "$RCD_AFTER" "rcd-quote-no-stray"
175
+
176
+ # --- reconcile: a pre-fix account still listing a job-owned bucket at root is a stray ---
177
+ A_STRAY="$(make_account "$T_C")"
178
+ export PROJECT_DIR="$T_C/platform"
179
+ # Seed the old flat-rule layout: inject `quotes` into the allowed-top-level fence.
180
+ awk '/^projects$/{print; print "quotes"; next} {print}' "$A_STRAY/SCHEMA.md" > "$A_STRAY/SCHEMA.md.tmp" && mv "$A_STRAY/SCHEMA.md.tmp" "$A_STRAY/SCHEMA.md"
181
+ RC_STRAY="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_STRAY")"
182
+ assert_grep "ontologyEntity=Quote owner=Job bucket=quotes expectedAtRoot=false stray=true" "$RC_STRAY" "rcd-quote-stray"
183
+
184
+ # insert_stray <schema_path> <name>: add <name> as the first entry inside the
185
+ # allowed-top-level fence (simulates an account whose list carries a bucket no
186
+ # longer in the ontology).
187
+ insert_stray() {
188
+ awk -v n="$2" '{print} /^```allowed-top-level$/{print n}' "$1" > "$1.tmp" && mv "$1.tmp" "$1"
189
+ }
190
+
191
+ # --- prune: stale bucket, absent on disk -> removed from list + logged ---
192
+ T_PRUNE="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"
193
+ A_P="$(make_account "$T_PRUNE")"; export PROJECT_DIR="$T_PRUNE/platform"
194
+ insert_stray "$A_P/SCHEMA.md" "inbound-invoices"
195
+ OUT_P="$(merge_owned_dirs_into_schema "$A_P")"
196
+ ALLOWED_P="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_P/SCHEMA.md")"
197
+ assert_nogrep "inbound-invoices" "$ALLOWED_P" "prune-empty-removed-from-list"
198
+ assert_grep "removed=inbound-invoices" "$OUT_P" "prune-empty-logged"
199
+
200
+ # --- keep: stale bucket that holds files -> retained + keptPopulated ---
201
+ T_KEEP="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"
202
+ A_K="$(make_account "$T_KEEP")"; export PROJECT_DIR="$T_KEEP/platform"
203
+ insert_stray "$A_K/SCHEMA.md" "inbound-invoices"
204
+ mkdir -p "$A_K/inbound-invoices"; echo "stamp" > "$A_K/inbound-invoices/stamp_invoice.py"
205
+ OUT_K="$(merge_owned_dirs_into_schema "$A_K")"
206
+ ALLOWED_K="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_K/SCHEMA.md")"
207
+ assert_grep "inbound-invoices" "$ALLOWED_K" "keep-populated-retained"
208
+ assert_grep "keptPopulated=inbound-invoices" "$OUT_K" "keep-populated-logged"
209
+ assert_nogrep "removed=inbound-invoices" "$OUT_K" "keep-populated-not-in-removed"
210
+
211
+ # --- fixed-seed survival: empty base dir is never pruned ---
212
+ T_SEED="$(make_tree '[]')"; A_S="$(make_account "$T_SEED")"; export PROJECT_DIR="$T_SEED/platform"
213
+ OUT_S="$(merge_owned_dirs_into_schema "$A_S")"
214
+ ALLOWED_S="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_S/SCHEMA.md")"
215
+ for d in documents url-get .git output; do
216
+ assert_grep "$d" "$ALLOWED_S" "seed-survives-$d"
217
+ done
218
+ assert_grep "removed=none" "$OUT_S" "seed-nothing-removed"
219
+
220
+ # --- idempotency after a prune: second run byte-identical (same brand) ---
221
+ export PROJECT_DIR="$T_PRUNE/platform"
222
+ B_P="$(shasum "$A_P/SCHEMA.md" | awk '{print $1}')"
223
+ merge_owned_dirs_into_schema "$A_P" >/dev/null
224
+ assert_eq "$(shasum "$A_P/SCHEMA.md" | awk '{print $1}')" "$B_P" "prune-idempotent"
156
225
 
157
226
  echo "PASS=$PASS FAIL=$FAIL"
158
227
  if [ "$FAIL" -ne 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
@@ -24,9 +24,35 @@ MARK_END = "<!-- plugin-owned-dirs:end -->"
24
24
  ONT_START = "<!-- ontology-buckets:start -->"
25
25
  ONT_END = "<!-- ontology-buckets:end -->"
26
26
 
27
+ # Base top-level names shipped in the account template plus `.quarantine`. This
28
+ # set is prune-protection only: a reconcile never *adds* a missing seed entry,
29
+ # it only shields a listed base dir from being flagged stale. Source (verbatim):
30
+ # docs/superpowers/plans/2026-06-23-account-filesystem-schema.md allowed-top-level.
31
+ FIXED_SEED = frozenset({
32
+ "projects", "contacts", "documents", "url-get", "output", "generated",
33
+ "extracted", "uploads", "agents", "specialists", "sites", "public",
34
+ "cache", "secrets", "state", "logs", "tmp", "SCHEMA.md", "account.json",
35
+ "AGENTS.md", ".claude", ".git", ".quarantine",
36
+ })
37
+
27
38
  LABEL_DIR_OVERRIDES = {"Site": "customer-sites"}
28
39
 
29
40
 
41
+ def _dir_holds_files(account_dir, name):
42
+ """True iff account_dir/<name> carries file content: a non-empty regular
43
+ file, or a directory whose subtree contains at least one regular file.
44
+ Absent path, empty dir, or a dir tree with no files -> False (prunable)."""
45
+ path = os.path.join(account_dir, name)
46
+ if not os.path.exists(path):
47
+ return False
48
+ if os.path.isfile(path):
49
+ return os.path.getsize(path) > 0
50
+ for _root, _dirs, files in os.walk(path):
51
+ if files:
52
+ return True
53
+ return False
54
+
55
+
30
56
  def _label_to_dir(label):
31
57
  """Map a Neo4j label to its file-schema bucket dir: kebab-case-plural,
32
58
  lowercased. camelCase word boundaries split on case. One override:
@@ -147,11 +173,14 @@ def resolve(project_dir):
147
173
 
148
174
 
149
175
  def _top_level_labels(schema_path):
150
- """Extract operator-entry labels from the entity table under the
176
+ """Extract (label, owner) rows from the entity table under the
151
177
  '## Top-level node types' heading of a vertical schema file. A row is
152
178
  included iff its 'Schema.org Type' cell is a schema:-namespaced type
153
- (excludes transport labels such as WhatsAppGroup -> cdm:Channel). Returns
154
- labels in table order. No such heading -> empty list."""
179
+ (excludes transport labels such as WhatsAppGroup -> cdm:Channel). `owner`
180
+ is the containment parent from the 'Owner' column: None when the table has
181
+ no Owner column, '' when the column is present but the cell is blank (a root
182
+ entity), else the parent label (a contained entity). Returns rows in table
183
+ order. No such heading -> empty list."""
155
184
  try:
156
185
  with open(schema_path) as f:
157
186
  lines = f.read().split("\n")
@@ -176,6 +205,8 @@ def _top_level_labels(schema_path):
176
205
  cols = [c.strip() for c in lines[hdr].strip().strip("|").split("|")]
177
206
  label_i = cols.index("Neo4j Label")
178
207
  type_i = cols.index("Schema.org Type")
208
+ owner_i = cols.index("Owner") if "Owner" in cols else None
209
+ need = max(label_i, type_i, owner_i if owner_i is not None else 0)
179
210
  out = []
180
211
  for k in range(hdr + 2, len(lines)): # +2 skips the header separator row
181
212
  row = lines[k]
@@ -184,19 +215,24 @@ def _top_level_labels(schema_path):
184
215
  if "|" not in row or not row.strip():
185
216
  break
186
217
  cells = [c.strip() for c in row.strip().strip("|").split("|")]
187
- if len(cells) <= max(label_i, type_i):
218
+ if len(cells) <= need:
188
219
  continue
189
220
  label = cells[label_i].strip("`").strip()
190
221
  stype = cells[type_i].strip("`").strip()
191
- if label and stype.startswith("schema:"):
192
- out.append(label)
222
+ if not (label and stype.startswith("schema:")):
223
+ continue
224
+ owner = None if owner_i is None else cells[owner_i].strip("`").strip()
225
+ out.append((label, owner))
193
226
  return out
194
227
 
195
228
 
196
229
  def resolve_domain_buckets(project_dir):
197
230
  """Domain operator-entity buckets derived from the brand's vertical
198
- ontology. Returns [{dir, label}] in table order, deduped by dir. Empty when
199
- the brand declares no vertical or the vertical file has no top-level
231
+ ontology. Returns [{dir, label, owner}] in table order, deduped by dir.
232
+ `owner` is None (the vertical table has no Owner column), '' (a root entity
233
+ that owns a top-level bucket), or the parent label (a contained entity that
234
+ gets no root bucket, its records living under the parent's folder). Empty
235
+ when the brand declares no vertical or the vertical file has no top-level
200
236
  section."""
201
237
  vertical = _read_vertical(project_dir)
202
238
  if not vertical:
@@ -204,15 +240,22 @@ def resolve_domain_buckets(project_dir):
204
240
  schema_path = os.path.join(project_dir, "plugins", "memory", "references", vertical + ".md")
205
241
  seen = set()
206
242
  out = []
207
- for label in _top_level_labels(schema_path):
243
+ for label, owner in _top_level_labels(schema_path):
208
244
  d = _label_to_dir(label)
209
245
  if d in seen:
210
246
  continue
211
247
  seen.add(d)
212
- out.append({"dir": d, "label": label})
248
+ out.append({"dir": d, "label": label, "owner": owner})
213
249
  return out
214
250
 
215
251
 
252
+ def _is_root(owner):
253
+ """A domain bucket is a root (own top-level dir) when its Owner is blank
254
+ ('') or the vertical declares no Owner column at all (None). A non-empty
255
+ owner names the containing parent, so the entity nests and emits no root."""
256
+ return owner is None or owner == ""
257
+
258
+
216
259
  def _allowed_block(text):
217
260
  """Return (list_of_entries, start_idx, end_idx) for the fenced
218
261
  allowed-top-level block. end_idx is the closing-fence line index."""
@@ -246,28 +289,49 @@ def merge(project_dir, account_dir):
246
289
  domain = resolve_domain_buckets(project_dir)
247
290
  schema_path = os.path.join(account_dir, "SCHEMA.md")
248
291
  if not os.path.isfile(schema_path):
249
- print(f"brand={brand} added=none domainBuckets=none")
292
+ print(f"brand={brand} added=none domainBuckets=none removed=none keptPopulated=none")
250
293
  return
251
294
  with open(schema_path) as f:
252
295
  text = f.read()
253
296
  entries, start, end = _allowed_block(text)
254
297
  added = []
255
298
  added_domain = []
299
+ removed = []
300
+ kept_populated = []
256
301
  if entries is not None:
257
302
  present = set(entries)
258
- lines = text.split("\n")
303
+ # Only ROOT domain buckets belong at the account root; a contained
304
+ # entity (non-blank Owner) nests under its owner and is not a target,
305
+ # so a pre-fix account's empty owned bucket prunes while a populated one
306
+ # is kept-and-logged like any other stale bucket.
307
+ target = FIXED_SEED | {e["dir"] for e in owned} | {b["dir"] for b in domain if _is_root(b["owner"])}
308
+ # Reconcile existing entries: keep in-target; keep populated strays
309
+ # (logged); drop empty/absent strays (logged).
310
+ new_entries = []
311
+ for entry in entries:
312
+ if entry in target:
313
+ new_entries.append(entry)
314
+ elif _dir_holds_files(account_dir, entry):
315
+ new_entries.append(entry)
316
+ kept_populated.append(entry)
317
+ else:
318
+ removed.append(entry)
319
+ # Append newly-projected owned then domain dirs not already present.
259
320
  insert = []
260
321
  for e in owned:
261
322
  if e["dir"] not in present and e["dir"] not in insert:
262
323
  insert.append(e["dir"])
263
324
  added.append(e["dir"])
264
325
  for b in domain:
326
+ if not _is_root(b["owner"]):
327
+ continue # contained entity: no root bucket, nests under its owner
265
328
  if b["dir"] not in present and b["dir"] not in insert:
266
329
  insert.append(b["dir"])
267
330
  added_domain.append(b["dir"])
268
- if insert:
269
- lines[end:end] = insert
270
- text = "\n".join(lines)
331
+ new_entries.extend(insert)
332
+ lines = text.split("\n")
333
+ lines[start + 1:end] = new_entries
334
+ text = "\n".join(lines)
271
335
  # Regenerate both descriptive regions wholesale (idempotent).
272
336
  text = _strip_region(text, MARK_START, MARK_END)
273
337
  text = _strip_region(text, ONT_START, ONT_END)
@@ -284,20 +348,33 @@ def merge(project_dir, account_dir):
284
348
  owned_dirs = {e["dir"] for e in owned}
285
349
  # A dir already claimed by a plugin (described in the plugin-owned region)
286
350
  # is not also described here, so a name collision cannot double-describe.
287
- domain_region = [b for b in domain if b["dir"] not in owned_dirs]
288
- if domain_region:
351
+ described = [b for b in domain if b["dir"] not in owned_dirs]
352
+ root_described = [b for b in described if _is_root(b["owner"])]
353
+ nested_described = [b for b in described if not _is_root(b["owner"])]
354
+ if root_described or nested_described:
289
355
  region = [ONT_START, "## Domain entity buckets (from the graph ontology)", "",
290
356
  "One bucket per top-level operator-entry node type of this brand's",
291
357
  "business ontology. File a job's artifacts under its entity folder;",
292
- "the folder may hold a subtree.", ""]
293
- for b in domain_region:
358
+ "the folder may hold a subtree. A contained entity has no root",
359
+ "bucket; its records live under the owning entity's folder.", ""]
360
+ for b in root_described:
294
361
  region.append(f"- `{b['dir']}/` - one folder per {b['label']} record.")
362
+ for b in nested_described:
363
+ parent_dir = _label_to_dir(b["owner"])
364
+ region.append(f"- `{b['label']}` records live under `{parent_dir}/` "
365
+ f"(owned by `{b['owner']}`), not a root bucket.")
295
366
  region.append(ONT_END)
296
367
  text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
297
368
  with open(schema_path, "w") as f:
298
369
  f.write(text)
370
+ root_dirs = [b["dir"] for b in domain if _is_root(b["owner"])]
371
+ nested_pairs = [f"{b['label']}<-{b['owner']}" for b in domain if not _is_root(b["owner"])]
299
372
  print(f"brand={brand} added={','.join(added) if added else 'none'} "
300
- f"domainBuckets={','.join(added_domain) if added_domain else 'none'}")
373
+ f"domainBuckets={','.join(added_domain) if added_domain else 'none'} "
374
+ f"removed={','.join(removed) if removed else 'none'} "
375
+ f"keptPopulated={','.join(kept_populated) if kept_populated else 'none'} "
376
+ f"roots={','.join(root_dirs) if root_dirs else 'none'} "
377
+ f"nested={','.join(nested_pairs) if nested_pairs else 'none'}")
301
378
 
302
379
 
303
380
  def reconcile(project_dir, account_dir):
@@ -316,9 +393,21 @@ def reconcile(project_dir, account_dir):
316
393
  domain = resolve_domain_buckets(project_dir)
317
394
  if vertical and not domain:
318
395
  print(f"brand={brand} ontologyVertical={vertical} topLevelSection=absent")
396
+ # A vertical whose top-level table carries no Owner column cannot state
397
+ # containment; every entity falls back to root, but loudly, never silently.
398
+ if vertical and any(b["owner"] is None for b in domain):
399
+ print(f"brand={brand} ontologyVertical={vertical} owner-source=absent")
319
400
  for b in domain:
320
- is_present = "true" if b["dir"] in present else "false"
321
- print(f"brand={brand} ontologyEntity={b['label']} bucket={b['dir']} present={is_present}")
401
+ if _is_root(b["owner"]):
402
+ is_present = "true" if b["dir"] in present else "false"
403
+ print(f"brand={brand} ontologyEntity={b['label']} bucket={b['dir']} present={is_present}")
404
+ else:
405
+ # Contained entity: no root bucket expected. A bucket still listed at
406
+ # root is a pre-fix stray the standing fs-reconcile quarantines once
407
+ # the owned bucket leaves the allowed set (unless it holds files).
408
+ is_stray = "true" if b["dir"] in present else "false"
409
+ print(f"brand={brand} ontologyEntity={b['label']} owner={b['owner']} "
410
+ f"bucket={b['dir']} expectedAtRoot=false stray={is_stray}")
322
411
 
323
412
 
324
413
  def main():
@@ -86,7 +86,8 @@ provision_account_dir() {
86
86
  {
87
87
  "matcher": "Bash",
88
88
  "hooks": [
89
- { "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" }
89
+ { "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" },
90
+ { "type": "command", "command": "bash $HOOKS_PATH/fs-schema-guard-bash-pre.sh" }
90
91
  ]
91
92
  },
92
93
  {
@@ -115,6 +116,12 @@ provision_account_dir() {
115
116
  }
116
117
  ],
117
118
  "PostToolUse": [
119
+ {
120
+ "matcher": "Bash",
121
+ "hooks": [
122
+ { "type": "command", "command": "bash $HOOKS_PATH/fs-schema-guard-bash-post.sh" }
123
+ ]
124
+ },
118
125
  {
119
126
  "matcher": "Agent",
120
127
  "hooks": [
@@ -0,0 +1,39 @@
1
+ export interface ReconcileCounts {
2
+ strayTopLevel: number;
3
+ overDeep: number;
4
+ quarantined: number;
5
+ badName: number;
6
+ }
7
+ /** The single per-run summary line body. A non-zero count is the standing signal
8
+ * that an agent authored folders the hard guards could not prevent. Pure
9
+ * formatter so the emitter and its test share one wording. */
10
+ export declare function formatReconcileLine(c: ReconcileCounts): string;
11
+ /** Parse the fenced ```allowed-top-level block from an account's SCHEMA.md —
12
+ * the same structured set fs-schema-guard.sh parses with awk. Returns the
13
+ * entry names (blank lines dropped). An absent block yields an empty set. */
14
+ export declare function parseAllowedTopLevel(schemaText: string): Set<string>;
15
+ /** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
16
+ * set skips the account (fail open, like the hook — an un-seeded legacy account
17
+ * must not have its whole root quarantined). Moves stray top-level entries to
18
+ * .quarantine/ with a manifest line; counts over-deep files without moving. */
19
+ export declare function reconcileAccount(accountDir: string, now: () => number): ReconcileCounts;
20
+ export interface ReconcileDeps {
21
+ accountsRoot: string;
22
+ logger: (line: string) => void;
23
+ now?: () => number;
24
+ }
25
+ /** One reconcile pass over every account under the accounts root, then emit the
26
+ * single summary line. An account is any child dir with an account.json (the
27
+ * account marker the census uses). Per-account errors are swallowed so one bad
28
+ * account never aborts the run. */
29
+ export declare function runReconcile(deps: ReconcileDeps): void;
30
+ export interface AccountDirSchemaReconcile {
31
+ start(): void;
32
+ stop(): void;
33
+ }
34
+ /** Standing periodic reconcile. Unref'd interval so it never holds the process
35
+ * open; each tick re-runs the pass best-effort. */
36
+ export declare function createAccountDirSchemaReconcile(deps: ReconcileDeps & {
37
+ intervalMs: number;
38
+ }): AccountDirSchemaReconcile;
39
+ //# sourceMappingURL=account-dir-schema-reconcile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-dir-schema-reconcile.d.ts","sourceRoot":"","sources":["../src/account-dir-schema-reconcile.ts"],"names":[],"mappings":"AA2FA,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;+DAE+D;AAC/D,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,eAAe,GAAG,MAAM,CAE9D;AAED;;8EAE8E;AAC9E,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAcpE;AA0FD;;;gFAGgF;AAChF,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,MAAM,GAAG,eAAe,CAwDvF;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED;;;oCAGoC;AACpC,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAwBtD;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,IAAI,IAAI,CAAA;IACb,IAAI,IAAI,IAAI,CAAA;CACb;AAED;oDACoD;AACpD,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,aAAa,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,GAC3C,yBAAyB,CAiB3B"}