@rubytech/create-maxy-code 0.1.487 → 0.1.489

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 (130) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
  4. package/payload/platform/lib/active-rules/package-lock.json +12 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
  6. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
  7. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
  8. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
  9. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
  10. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
  11. package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
  12. package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
  13. package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
  14. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  15. package/payload/platform/lib/graph-style/dist/index.js +25 -1
  16. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  17. package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
  18. package/payload/platform/lib/graph-style/src/index.ts +27 -0
  19. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
  20. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  21. package/payload/platform/lib/storage-broker/dist/audit.d.ts +5 -0
  22. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  23. package/payload/platform/lib/storage-broker/dist/audit.js +3 -2
  24. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  25. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
  26. package/payload/platform/lib/storage-broker/src/audit.ts +8 -2
  27. package/payload/platform/neo4j/schema.cypher +105 -0
  28. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
  29. package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
  30. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
  31. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
  32. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +77 -9
  33. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
  34. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +72 -0
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +43 -7
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +22 -7
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -2
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +23 -5
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +3 -0
  40. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +46 -5
  41. package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
  42. package/payload/platform/plugins/docs/references/internals.md +2 -0
  43. package/payload/platform/plugins/memory/PLUGIN.md +1 -0
  44. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
  45. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
  46. package/payload/platform/plugins/memory/references/schema-construction.md +13 -0
  47. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  48. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  49. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  50. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
  51. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  52. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
  53. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
  54. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
  55. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  56. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
  57. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  58. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
  59. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
  60. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
  61. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
  62. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
  63. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  64. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -0
  65. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +22 -2
  66. package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
  67. package/payload/platform/scripts/quarantine-restore.mjs +175 -0
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +55 -72
  71. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
  73. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  74. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
  75. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  76. package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
  77. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  78. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
  79. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  80. package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
  81. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  82. package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
  83. package/payload/server/{chunk-REWXLAPC.js → chunk-UFXRUKXO.js} +4 -2
  84. package/payload/server/public/activity.html +6 -6
  85. package/payload/server/public/assets/{AdminLoginScreens-DF9S5Rca.js → AdminLoginScreens-Dic_fPt6.js} +1 -1
  86. package/payload/server/public/assets/{AdminShell-B0PNDCAb.js → AdminShell-JN7rZ8aG.js} +1 -1
  87. package/payload/server/public/assets/{Checkbox-s5X6dso4.js → Checkbox-CB74kINA.js} +1 -1
  88. package/payload/server/public/assets/{activity-BRBsaalV.js → activity-DbfpHg3i.js} +1 -1
  89. package/payload/server/public/assets/{admin-BoVCP4Mw.js → admin-DvQPTxqe.js} +1 -1
  90. package/payload/server/public/assets/{bot-BVO9Uqr_.js → bot-DnuyRsLS.js} +1 -1
  91. package/payload/server/public/assets/{browser-BVW4FFii.js → browser-q4EFqKvR.js} +1 -1
  92. package/payload/server/public/assets/{calendar-CWsD3uLk.js → calendar-C-q9kWmA.js} +1 -1
  93. package/payload/server/public/assets/chat-DcefIV9q.js +1 -0
  94. package/payload/server/public/assets/chevron-down-Blma_CKl.js +1 -0
  95. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +1 -0
  96. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +1 -0
  97. package/payload/server/public/assets/data-Bu-5SUmR.js +1 -0
  98. package/payload/server/public/assets/{graph-mJ9ky5dc.js → graph-Cyh6FWaV.js} +1 -1
  99. package/payload/server/public/assets/graph-labels-CUQtXFKW.js +1 -0
  100. package/payload/server/public/assets/{maximize-2-yucCepu_.js → maximize-2-k3Fu5hdP.js} +1 -1
  101. package/payload/server/public/assets/{operator-911z_Fy_.js → operator-8NVpNLeD.js} +1 -1
  102. package/payload/server/public/assets/{page-Cv0qUwMG.js → page-Cno8NNMk.js} +1 -1
  103. package/payload/server/public/assets/{page-CgSgj8Cy.js → page-DhwPG5Zf.js} +1 -1
  104. package/payload/server/public/assets/{public-BzwveFwz.js → public-BJ5Oo2Fw.js} +1 -1
  105. package/payload/server/public/assets/{rotate-ccw-BH51cf2Q.js → rotate-ccw-D-ov5jM4.js} +1 -1
  106. package/payload/server/public/assets/tasks-DHMxTZVD.js +1 -0
  107. package/payload/server/public/assets/{time-entry-format-DPdGkp1C.js → time-entry-format-EmV5lz6o.js} +1 -1
  108. package/payload/server/public/assets/{triangle-alert-DO2rjjfV.js → triangle-alert-CivyPfAL.js} +1 -1
  109. package/payload/server/public/assets/{useCopyFeedback-HMSM7-Ir.js → useCopyFeedback-hbnPS-r9.js} +1 -1
  110. package/payload/server/public/assets/{useSubAccountSwitcher-2ojOOYNo.css → useSubAccountSwitcher-kwhG-aJj.css} +1 -1
  111. package/payload/server/public/assets/{useVoiceRecorder-BcukQIza.js → useVoiceRecorder-QN9_GqB_.js} +1 -1
  112. package/payload/server/public/browser.html +4 -4
  113. package/payload/server/public/calendar.html +6 -6
  114. package/payload/server/public/chat.html +13 -13
  115. package/payload/server/public/data.html +10 -10
  116. package/payload/server/public/graph.html +11 -11
  117. package/payload/server/public/index.html +16 -16
  118. package/payload/server/public/operator.html +15 -15
  119. package/payload/server/public/public.html +13 -13
  120. package/payload/server/public/tasks.html +6 -6
  121. package/payload/server/server.js +1036 -784
  122. package/payload/server/{src-JJ6PHO5I.js → src-YJF3UJNR.js} +1 -1
  123. package/payload/server/public/assets/chat-DKGREuIO.js +0 -1
  124. package/payload/server/public/assets/chevron-down-m581tl1J.js +0 -1
  125. package/payload/server/public/assets/chevron-left-BAY0xfmR.js +0 -1
  126. package/payload/server/public/assets/chevron-right-Dy_REdYZ.js +0 -1
  127. package/payload/server/public/assets/data-CtOxa94P.js +0 -1
  128. package/payload/server/public/assets/graph-labels-C8-sIKuv.js +0 -1
  129. package/payload/server/public/assets/tasks-21egY7A6.js +0 -1
  130. /package/payload/server/public/assets/{useSubAccountSwitcher-DLvqvfoc.js → useSubAccountSwitcher-DotTrvX3.js} +0 -0
@@ -73,6 +73,20 @@ ALLOWED_MX="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_MX/SCH
73
73
  assert_nogrep "quoting" "$ALLOWED_MX" "mx-no-quoting"
74
74
  assert_nogrep "Plugin-owned top-level workspaces" "$(cat "$A_MX/SCHEMA.md")" "mx-no-section"
75
75
 
76
+ # --- platform plugins declare pages (storage-broker) + archive (memory) ---
77
+ # Task 1891: these two plugin-owned root dirs must land in the allowed block so
78
+ # the account-dir reconcile stops quarantining them. Symlink the real plugin
79
+ # dirs so the case exercises the shipped PLUGIN.md declaration.
80
+ T_PA="$(make_tree '[]')"
81
+ ln -s "$REAL_PROJECT_DIR/plugins/storage-broker" "$T_PA/platform/plugins/storage-broker"
82
+ ln -s "$REAL_PROJECT_DIR/plugins/memory" "$T_PA/platform/plugins/memory"
83
+ A_PA="$(make_account "$T_PA")"
84
+ export PROJECT_DIR="$T_PA/platform"
85
+ merge_owned_dirs_into_schema "$A_PA" >/dev/null
86
+ ALLOWED_PA="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_PA/SCHEMA.md")"
87
+ assert_grep "pages" "$ALLOWED_PA" "pa-allowlist-has-pages"
88
+ assert_grep "archive" "$ALLOWED_PA" "pa-allowlist-has-archive"
89
+
76
90
  # --- reconcile: present=false on bare schema, present=true after merge ---
77
91
  A_RC="$(make_account "$T_SD")"
78
92
  export PROJECT_DIR="$T_SD/platform"
@@ -117,11 +131,11 @@ OUT_C="$(merge_owned_dirs_into_schema "$A_C")"
117
131
  # Match a non-empty derived list, not the bare key (which prints even for zero).
118
132
  assert_grep "domainBuckets=jobs" "$OUT_C" "con-domain-log"
119
133
  # 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"
134
+ assert_grep "roots=jobs,customers,customer-sites,assets,parts,suppliers,ppm-contracts" "$OUT_C" "con-roots-log"
121
135
  assert_grep "nested=Visit<-Job,Quote<-Job,PurchaseOrder<-Job" "$OUT_C" "con-nested-log"
122
136
  ALLOWED_C="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_C/SCHEMA.md")"
123
137
  # Root entities (blank Owner) get a top-level bucket.
124
- for d in jobs customers customer-sites assets parts ppm-contracts; do
138
+ for d in jobs customers customer-sites assets parts ppm-contracts suppliers; do
125
139
  assert_grep "$d" "$ALLOWED_C" "con-allow-$d"
126
140
  done
127
141
  # Job-owned entities (Owner=Job) do NOT get a root bucket.
@@ -137,6 +151,10 @@ assert_grep '`Quote` records live under `jobs/` (owned by `Job`), not a root buc
137
151
  B_C="$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')"
138
152
  merge_owned_dirs_into_schema "$A_C" >/dev/null
139
153
  assert_eq "$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')" "$B_C" "con-idempotent"
154
+ # Supplier is a root bucket (Task 1892): received invoices file under
155
+ # suppliers/<supplier>/Invoices/, not a stray top-level inbound-invoices/.
156
+ RCS="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_C")"
157
+ assert_grep "ontologyEntity=Supplier bucket=suppliers present=true" "$RCS" "con-supplier-root-present"
140
158
 
141
159
  # --- estate-agent vertical: root buckets only, listing-owned entities nested ---
142
160
  T_E="$(make_vertical_tree '["real-agent"]' 'schema-estate-agent')"; A_E="$(make_account "$T_E")"
@@ -151,6 +169,8 @@ for d in viewings offers; do
151
169
  assert_nogrep "$d" "$ALLOWED_E" "ea-noroot-$d"
152
170
  done
153
171
  assert_nogrep "jobs" "$ALLOWED_E" "ea-no-sitedesk-jobs"
172
+ # The suppliers/ bucket is construction-only; it must not leak into another vertical.
173
+ assert_nogrep "suppliers" "$ALLOWED_E" "ea-no-suppliers"
154
174
 
155
175
  # --- no vertical declared: generic base only ---
156
176
  T_N="$(make_tree '[]')"; A_N="$(make_account "$T_N")"
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # Tests the one-shot quarantine restore (Task 1899): a free target is moved back,
3
+ # an occupied target is skipped with both copies surviving, an already-restored
4
+ # entry reports absent, a malformed line reports unparsable, and --dry-run
5
+ # changes nothing on disk.
6
+ set -uo pipefail
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ RESTORE="$SCRIPT_DIR/../quarantine-restore.mjs"
9
+ PASS=0; FAIL=0; FAILED=()
10
+ assert_grep() { if printf '%s' "$2" | grep -qF "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected to find [$1]"); fi; }
11
+ assert_nogrep() { if printf '%s' "$2" | grep -qF "$1"; then FAIL=$((FAIL+1)); FAILED+=("$3: expected NOT to find [$1]"); else PASS=$((PASS+1)); fi; }
12
+ assert_file() { if [ -e "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected file [$1]"); fi; }
13
+ assert_nofile() { if [ -e "$1" ]; then FAIL=$((FAIL+1)); FAILED+=("$2: expected NO file [$1]"); else PASS=$((PASS+1)); fi; }
14
+ assert_eq() { if [ "$1" = "$2" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected [$2] got [$1]"); fi; }
15
+
16
+ # One accounts root with a single account whose manifest covers all four cases.
17
+ make_tree() {
18
+ local T; T="$(mktemp -d)"
19
+ local A="$T/acct-1"
20
+ mkdir -p "$A/.quarantine"
21
+ printf '{}' > "$A/account.json"
22
+
23
+ # (a) free target: quarantined copy exists, nothing at the root.
24
+ printf 'portal' > "$A/.quarantine/data-portal.json"
25
+ # (b) occupied target: quarantined copy AND a live copy the platform rewrote.
26
+ printf 'old' > "$A/.quarantine/wa-channel-bindings.json"
27
+ printf 'live' > "$A/wa-channel-bindings.json"
28
+ # (c) already restored by hand: manifest names a source that is gone.
29
+ printf 'restored' > "$A/session-titles.json"
30
+
31
+ cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
32
+ {"name":"data-portal.json","originalPath":"data-portal.json","reason":"top-level","quarantinePath":".quarantine/data-portal.json","movedAt":"2026-07-21T19:16:05.216Z"}
33
+ {"name":"session-titles.json","originalPath":"session-titles.json","reason":"top-level","quarantinePath":".quarantine/session-titles.json","movedAt":"2026-07-21T19:20:00.000Z"}
34
+ not json at all
35
+ {"name":"wa-channel-bindings.json","originalPath":"wa-channel-bindings.json","reason":"top-level","quarantinePath":".quarantine/wa-channel-bindings.json","movedAt":"2026-07-21T20:45:00.000Z"}
36
+ EOF
37
+ printf '%s' "$T"
38
+ }
39
+
40
+ # --- dry run changes nothing -------------------------------------------------
41
+ T="$(make_tree)"; A="$T/acct-1"
42
+ OUT="$(node "$RESTORE" --accounts-root "$T" --dry-run 2>&1)"
43
+ assert_grep 'result=moved' "$OUT" "dry-run reports the move it would make"
44
+ assert_grep 'dry-run=1' "$OUT" "dry-run marks its lines"
45
+ assert_nofile "$A/data-portal.json" "dry-run leaves the root untouched"
46
+ assert_file "$A/.quarantine/data-portal.json" "dry-run leaves the quarantined copy"
47
+ assert_nofile "$A/.quarantine/restored.jsonl" "dry-run writes no restored.jsonl"
48
+ rm -rf "$T"
49
+
50
+ # --- real run ----------------------------------------------------------------
51
+ T="$(make_tree)"; A="$T/acct-1"
52
+ OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"
53
+
54
+ assert_grep 'op=restore account=acct-1 name=data-portal.json result=moved' "$OUT" "free target moved"
55
+ assert_file "$A/data-portal.json" "free target is back at the root"
56
+ assert_nofile "$A/.quarantine/data-portal.json" "free target no longer in quarantine"
57
+
58
+ assert_grep 'name=wa-channel-bindings.json result=skipped-live-copy' "$OUT" "occupied target skipped"
59
+ assert_grep 'live' "$(cat "$A/wa-channel-bindings.json")" "live copy still wins"
60
+ assert_file "$A/.quarantine/wa-channel-bindings.json" "quarantined copy survives the skip"
61
+
62
+ assert_grep 'name=session-titles.json result=absent' "$OUT" "already-restored source reports absent"
63
+ assert_grep 'restored' "$(cat "$A/session-titles.json")" "hand-restored copy untouched"
64
+
65
+ assert_grep 'line=3 result=unparsable' "$OUT" "malformed manifest line is reported"
66
+
67
+ assert_file "$A/.quarantine/manifest.jsonl" "manifest is left intact"
68
+ RESTORED="$(cat "$A/.quarantine/restored.jsonl")"
69
+ assert_grep 'data-portal.json' "$RESTORED" "restored.jsonl records the move"
70
+ assert_nogrep 'wa-channel-bindings.json' "$RESTORED" "restored.jsonl records only moves"
71
+ assert_nogrep 'session-titles.json' "$RESTORED" "restored.jsonl records only moves"
72
+ rm -rf "$T"
73
+
74
+ # --- a name quarantined twice restores the NEWEST copy ------------------------
75
+ # The sweep took wa-channel-bindings.json up to four times on one live account,
76
+ # the platform rewriting it between each. The deleted quarantineDest() took the
77
+ # unsuffixed name first and appended .1 to the next collision, so .1 IS the newer
78
+ # copy. Walking the append-only manifest oldest-first would put the stalest copy
79
+ # back, and the newer record would then find the target occupied.
80
+ T="$(mktemp -d)"; A="$T/acct-2"
81
+ mkdir -p "$A/.quarantine"
82
+ printf '{}' > "$A/account.json"
83
+ printf 'OLDER' > "$A/.quarantine/data-portal.json"
84
+ printf 'NEWER' > "$A/.quarantine/data-portal.json.1"
85
+ cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
86
+ {"name":"data-portal.json","originalPath":"data-portal.json","quarantinePath":".quarantine/data-portal.json","movedAt":"2026-07-21T19:16:05.216Z"}
87
+ {"name":"data-portal.json","originalPath":"data-portal.json","quarantinePath":".quarantine/data-portal.json.1","movedAt":"2026-07-21T20:45:00.000Z"}
88
+ EOF
89
+ OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"
90
+ assert_grep 'NEWER' "$(cat "$A/data-portal.json")" "newest quarantined copy is the one restored"
91
+ # The target was created by THIS run, not by a live platform rewrite. Calling it
92
+ # skipped-live-copy would tell the operator the platform holds a current copy.
93
+ assert_grep 'result=skipped-superseded' "$OUT" "the older copy is superseded, not live"
94
+ assert_nogrep 'result=skipped-live-copy' "$OUT" "no live copy existed on this tree"
95
+ assert_file "$A/.quarantine/data-portal.json" "the older copy is left in quarantine, never deleted"
96
+ rm -rf "$T"
97
+
98
+ # --- a record pointing outside its account is refused -------------------------
99
+ # .quarantine is in the write guard's allowed-top-level set, so an account-scoped
100
+ # agent can append to its own manifest. This script runs as the platform user
101
+ # across every account, so an escaping path would move one account's live data
102
+ # into another.
103
+ T="$(mktemp -d)"
104
+ mkdir -p "$T/acct-a/.quarantine" "$T/acct-b"
105
+ printf '{}' > "$T/acct-a/account.json"
106
+ printf '{}' > "$T/acct-b/account.json"
107
+ printf 'LIVE' > "$T/acct-b/wa-channel-bindings.json"
108
+ printf 'x' > "$T/acct-a/.quarantine/evil.json"
109
+ cat > "$T/acct-a/.quarantine/manifest.jsonl" <<'EOF'
110
+ {"name":"evil.json","originalPath":"../acct-b/planted.json","quarantinePath":".quarantine/evil.json"}
111
+ {"name":"steal","originalPath":"stolen.json","quarantinePath":"../acct-b/wa-channel-bindings.json"}
112
+ EOF
113
+ OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"; RC=$?
114
+ assert_grep 'result=refused-outside-account' "$OUT" "escaping record is refused"
115
+ assert_grep 'refused=2' "$OUT" "both escaping records are counted"
116
+ assert_nofile "$T/acct-b/planted.json" "nothing is planted in the other account"
117
+ assert_nofile "$T/acct-a/stolen.json" "nothing is stolen from the other account"
118
+ assert_file "$T/acct-b/wa-channel-bindings.json" "the other account's live file is untouched"
119
+ assert_eq "$RC" "1" "a refused record makes the run exit non-zero"
120
+ rm -rf "$T"
121
+
122
+ # --- a failed rename reaches the summary and the exit code --------------------
123
+ # A restore that cannot complete leaves data in quarantine. The operator runs
124
+ # this once and reads the tail line, so a clean summary with a silent stderr
125
+ # line would be the same silent-skip failure this task exists to end.
126
+ T="$(mktemp -d)"; A="$T/acct-3"
127
+ mkdir -p "$A/.quarantine"
128
+ printf '{}' > "$A/account.json"
129
+ printf 'x' > "$A/.quarantine/orphan.json"
130
+ # The target's parent bucket no longer exists, so the target itself is absent
131
+ # (not a live copy) and the rename fails with ENOENT.
132
+ cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
133
+ {"name":"orphan.json","originalPath":"gone-bucket/orphan.json","quarantinePath":".quarantine/orphan.json","movedAt":"2026-07-21T19:16:05.216Z"}
134
+ EOF
135
+ OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"; RC=$?
136
+ assert_grep 'result=failed' "$OUT" "the failed rename is reported"
137
+ assert_grep 'failed=1' "$OUT" "the failure reaches the summary line"
138
+ assert_eq "$RC" "1" "a failed restore makes the run exit non-zero"
139
+ rm -rf "$T"
140
+
141
+ # --- summary -----------------------------------------------------------------
142
+ echo "PASS=$PASS FAIL=$FAIL"
143
+ if [ "$FAIL" -gt 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // Task 1899 — one-shot restore of everything the account-dir schema reconcile
3
+ // moved into <account>/.quarantine/ before it was made report-only. The sweep's
4
+ // allowed-list never matched what the platform and its agents write at an
5
+ // account root, so it took live features and live data dark with no error: the
6
+ // portal config, live signed agreements, the channel bindings.
7
+ //
8
+ // Per account it walks .quarantine/manifest.jsonl NEWEST-FIRST (the manifest is
9
+ // append-only, so that is reverse file order). Newest-first is what makes the
10
+ // occupied case correct for a name quarantined more than once: the most recent
11
+ // copy is considered first, and every older copy of that name then finds the
12
+ // target occupied. The live copy always wins, because the platform rewrote it
13
+ // after the move — four wa-channel-bindings.json records on one account prove
14
+ // that happened.
15
+ //
16
+ // Never deletes. The manifest is left intact so the history of what moved and
17
+ // what came back both survive.
18
+ //
19
+ // Usage: node quarantine-restore.mjs --accounts-root <dir> [--dry-run]
20
+
21
+ import { existsSync, readdirSync, readFileSync, renameSync, appendFileSync } from 'node:fs'
22
+ import { join, resolve, sep } from 'node:path'
23
+
24
+ const TAG = '[quarantine-restore]'
25
+ const QUARANTINE_DIR = '.quarantine'
26
+
27
+ /** True when `path` is `root` itself or lives underneath it. Both arguments must
28
+ * already be absolute. */
29
+ function isInside(root, path) {
30
+ return path === root || path.startsWith(root + sep)
31
+ }
32
+
33
+ function parseArgs(argv) {
34
+ let accountsRoot = null
35
+ let dryRun = false
36
+ for (let i = 0; i < argv.length; i++) {
37
+ if (argv[i] === '--accounts-root') {
38
+ accountsRoot = argv[i + 1] ?? null
39
+ i++
40
+ } else if (argv[i] === '--dry-run') {
41
+ dryRun = true
42
+ }
43
+ }
44
+ return { accountsRoot, dryRun }
45
+ }
46
+
47
+ const { accountsRoot, dryRun } = parseArgs(process.argv.slice(2))
48
+ if (!accountsRoot) {
49
+ console.error(`${TAG} usage: quarantine-restore.mjs --accounts-root <dir> [--dry-run]`)
50
+ process.exit(1)
51
+ }
52
+ if (!existsSync(accountsRoot)) {
53
+ console.error(`${TAG} accounts root not found: ${accountsRoot}`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const suffix = dryRun ? ' dry-run=1' : ''
58
+ const totals = {
59
+ moved: 0,
60
+ 'skipped-live-copy': 0,
61
+ 'skipped-superseded': 0,
62
+ absent: 0,
63
+ unparsable: 0,
64
+ refused: 0,
65
+ failed: 0,
66
+ }
67
+ let accounts = 0
68
+
69
+ for (const entry of readdirSync(accountsRoot)) {
70
+ const accountDir = join(accountsRoot, entry)
71
+ const accountRoot = resolve(accountDir)
72
+ const quarantineRoot = resolve(accountDir, QUARANTINE_DIR)
73
+ const manifestPath = join(accountDir, QUARANTINE_DIR, 'manifest.jsonl')
74
+ if (!existsSync(manifestPath)) continue
75
+ accounts++
76
+
77
+ // Names this run put back at the account root. A later record naming one of
78
+ // them found the target occupied by THIS run, not by a live copy the platform
79
+ // rewrote — two different states that must not share one outcome label.
80
+ const restoredThisRun = new Set()
81
+
82
+ let lines
83
+ try {
84
+ lines = readFileSync(manifestPath, 'utf-8').split('\n')
85
+ } catch (err) {
86
+ totals.failed++
87
+ console.error(
88
+ `${TAG} op=restore account=${entry} result=manifest-unreadable error=${err.message}`,
89
+ )
90
+ continue
91
+ }
92
+
93
+ // Newest-first: the manifest is append-only, so walk it backwards. The 1-based
94
+ // file line number is carried so an unparsable line can be pointed at.
95
+ const numbered = lines.map((text, i) => ({ text, lineNo: i + 1 })).filter((l) => l.text.trim())
96
+ for (const { text, lineNo } of numbered.reverse()) {
97
+ let rec
98
+ try {
99
+ rec = JSON.parse(text)
100
+ } catch {
101
+ rec = null
102
+ }
103
+ if (!rec || typeof rec.originalPath !== 'string' || typeof rec.quarantinePath !== 'string') {
104
+ // Reported, never silently skipped: a line dropped without a word is the
105
+ // exact failure class this task exists to end.
106
+ totals.unparsable++
107
+ console.log(`${TAG} op=restore account=${entry} line=${lineNo} result=unparsable${suffix}`)
108
+ continue
109
+ }
110
+
111
+ const source = resolve(accountDir, rec.quarantinePath)
112
+ const target = resolve(accountDir, rec.originalPath)
113
+ const name = rec.originalPath
114
+
115
+ // The manifest is a file inside the account, and .quarantine is in the write
116
+ // guard's allowed-top-level set, so an account-scoped agent can append to it.
117
+ // This script runs as the platform user across every account, so a record
118
+ // whose paths escape the account would move one account's live data into
119
+ // another. Every record the reconcile ever wrote was a bare basename plus a
120
+ // path under .quarantine/, so this refuses nothing genuine.
121
+ if (!isInside(quarantineRoot, source) || !isInside(accountRoot, target)) {
122
+ totals.refused++
123
+ console.error(
124
+ `${TAG} op=restore account=${entry} line=${lineNo} result=refused-outside-account${suffix}`,
125
+ )
126
+ continue
127
+ }
128
+
129
+ if (!existsSync(source)) {
130
+ totals.absent++
131
+ console.log(`${TAG} op=restore account=${entry} name=${name} result=absent${suffix}`)
132
+ continue
133
+ }
134
+ if (existsSync(target) || restoredThisRun.has(target)) {
135
+ // A target this run created is superseded, not live. Calling both
136
+ // skipped-live-copy would tell the operator the platform holds a current
137
+ // copy when what is really left is an older copy still in quarantine.
138
+ const outcome = restoredThisRun.has(target) ? 'skipped-superseded' : 'skipped-live-copy'
139
+ totals[outcome]++
140
+ console.log(`${TAG} op=restore account=${entry} name=${name} result=${outcome}${suffix}`)
141
+ continue
142
+ }
143
+
144
+ if (dryRun) {
145
+ totals.moved++
146
+ restoredThisRun.add(target)
147
+ console.log(`${TAG} op=restore account=${entry} name=${name} result=moved${suffix}`)
148
+ continue
149
+ }
150
+ try {
151
+ renameSync(source, target)
152
+ appendFileSync(
153
+ join(accountDir, QUARANTINE_DIR, 'restored.jsonl'),
154
+ JSON.stringify({ ...rec, restoredAt: new Date().toISOString() }) + '\n',
155
+ )
156
+ totals.moved++
157
+ restoredThisRun.add(target)
158
+ console.log(`${TAG} op=restore account=${entry} name=${name} result=moved`)
159
+ } catch (err) {
160
+ totals.failed++
161
+ console.error(
162
+ `${TAG} op=restore account=${entry} name=${name} result=failed error=${err.message}`,
163
+ )
164
+ }
165
+ }
166
+ }
167
+
168
+ console.log(
169
+ `${TAG} accounts=${accounts} moved=${totals.moved} skipped-live-copy=${totals['skipped-live-copy']} skipped-superseded=${totals['skipped-superseded']} absent=${totals.absent} unparsable=${totals.unparsable} refused=${totals.refused} failed=${totals.failed}${suffix}`,
170
+ )
171
+
172
+ // A rename that failed, a manifest that would not open, and a record refused for
173
+ // pointing outside its account all leave data in quarantine. The operator runs
174
+ // this once and reads the tail line, so the exit code has to say so too.
175
+ if (totals.failed > 0 || totals.refused > 0) process.exitCode = 1
@@ -1,9 +1,17 @@
1
1
  export interface ReconcileCounts {
2
2
  strayTopLevel: number;
3
3
  overDeep: number;
4
- quarantined: number;
5
4
  badName: number;
6
5
  }
6
+ export interface ReconcileResult extends ReconcileCounts {
7
+ /** The account-root entries counted as strays, by name — the same set
8
+ * strayTopLevel counts, so PROTECTED_TOP_LEVEL entries and dot-prefixed
9
+ * entries are absent from it, and an account that failed the SCHEMA.md
10
+ * fail-open check returns it empty. A count alone cannot distinguish six
11
+ * pieces of debris from a live plugin config; this list is what makes the
12
+ * report-only posture actionable. */
13
+ strayNames: string[];
14
+ }
7
15
  /** The single per-run summary line body. A non-zero count is the standing signal
8
16
  * that an agent authored folders the hard guards could not prevent. Pure
9
17
  * formatter so the emitter and its test share one wording. */
@@ -14,18 +22,27 @@ export declare function formatReconcileLine(c: ReconcileCounts): string;
14
22
  export declare function parseAllowedTopLevel(schemaText: string): Set<string>;
15
23
  /** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
16
24
  * 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;
25
+ * must not have its whole root reported as stray). Counts and names stray
26
+ * top-level entries; counts over-deep files and bad names. Moves nothing. */
27
+ export declare function reconcileAccount(accountDir: string): ReconcileResult;
20
28
  export interface ReconcileDeps {
21
29
  accountsRoot: string;
22
30
  logger: (line: string) => void;
23
- now?: () => number;
24
31
  }
25
- /** One reconcile pass over every account under the accounts root, then emit the
32
+ /** One reconcile pass over every account under the accounts root: one op=strays
33
+ * line naming the unaccounted-for entries of each account that has any, then the
26
34
  * single summary line. An account is any child dir with an account.json (the
27
35
  * account marker the census uses). Per-account errors are swallowed so one bad
28
- * account never aborts the run. */
36
+ * account never aborts the run.
37
+ *
38
+ * The op=strays line is the standing signal. When a feature that reads an
39
+ * account-root file goes dark, seeing that file named here says the schema does
40
+ * not account for it and it is still on disk, so declare it. The absence of its
41
+ * name reads as "gone" only for a file that could have been named at all: an
42
+ * entry in PROTECTED_TOP_LEVEL, a dot-prefixed entry, and every entry of an
43
+ * account whose SCHEMA.md is missing or has an empty allowed block are never
44
+ * named whether they are present or not. Check the account root itself before
45
+ * concluding a file is gone. */
29
46
  export declare function runReconcile(deps: ReconcileDeps): void;
30
47
  export interface AccountDirSchemaReconcile {
31
48
  start(): void;
@@ -1 +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"}
1
+ {"version":3,"file":"account-dir-schema-reconcile.d.ts","sourceRoot":"","sources":["../src/account-dir-schema-reconcile.ts"],"names":[],"mappings":"AAqFA,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAgB,SAAQ,eAAe;IACtD;;;;;0CAKsC;IACtC,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;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;AA+ED;;;8EAG8E;AAC9E,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAoCpE;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC/B;AAED;;;;;;;;;;;;;iCAaiC;AACjC,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAyBtD;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"}
@@ -11,43 +11,45 @@
11
11
  // the flat operator-data buckets, mirroring fs-schema-guard.sh's own depth rule
12
12
  // exactly (projects/contacts/documents cap at 3 path segments; every other
13
13
  // tool-owned bucket is unbounded and not walked), and (c) bad-named folders and
14
- // files under those same buckets, mirroring the guard's name convention. Stray
15
- // top-level entries are QUARANTINED (moved to <account>/.quarantine/ with a
16
- // manifest recording the original path, reason, and quarantine path so the move
17
- // is auditable and reversible); over-deep files and bad names are counted only.
18
- // Nothing is deleted, nothing is silently rewritten, and re-filing quarantined
19
- // content or renaming a bad name to its schema-correct home is the data-manager
20
- // specialist's job on dispatch, not this pass.
14
+ // files under those same buckets, mirroring the guard's name convention. All
15
+ // three are REPORTED, never moved: the pass used to quarantine stray top-level
16
+ // entries, and because its allowed-list lagged what the platform and its agents
17
+ // actually write at an account root, a mismatch on an entry something still read
18
+ // took that feature or that data dark with no error anywhere (Task 1899 — the
19
+ // portal config, live signed agreements and the channel bindings all went for
20
+ // exactly that reason). Nothing is moved, nothing is
21
+ // deleted, nothing is silently rewritten; re-filing a stray to its schema-correct
22
+ // home is the data-manager specialist's job on dispatch, not this pass.
21
23
  //
22
24
  // Shape mirrors the standing-audit family (rootless-client-audit.ts): a pure
23
25
  // formatter (unit-tested) + an impure per-account collector + a start/stop
24
26
  // factory whose setInterval is unref'd. Purely filesystem — no Neo4j, no graph
25
27
  // writes, no deletes. Best-effort by construction: a per-account error is caught
26
28
  // so one bad account never aborts the run.
27
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, appendFileSync, statSync, } from 'node:fs';
29
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
28
30
  import { join, relative, sep } from 'node:path';
29
31
  const TAG = '[fs-reconcile]';
30
32
  const QUARANTINE_DIR = '.quarantine';
31
- /** Platform control-plane entries the reconcile NEVER quarantines, regardless of
32
- * what an account's SCHEMA.md allowed set says. provision-account-dir.sh makes
33
+ /** Platform control-plane entries the reconcile NEVER counts as strays, regardless
34
+ * of what an account's SCHEMA.md allowed set says. provision-account-dir.sh makes
33
35
  * the account dir a git root (`.git`) and writes its hook-enforcement settings
34
- * into `.claude/`, and the account's identity lives in account.json; moving any
35
- * of these into .quarantine would brick the account (lost git root, disabled
36
- * hooks, unresolvable identity). SCHEMA.md is refreshed on every provision, but
37
- * an account carrying a stale/older-template/hand-edited schema whose allowed
38
- * block omits one of these must not have it moved. This is a fail-SAFE belt on
39
- * the control plane, the mirror of the guard's fail-OPEN on a missing schema —
40
- * operator strays are still quarantined; the account's own machinery is not.
36
+ * into `.claude/`, and the account's identity lives in account.json. SCHEMA.md is
37
+ * refreshed on every provision, but an account carrying a stale/older-template/
38
+ * hand-edited schema whose allowed block omits one of these must not be reported
39
+ * on every run for machinery it is required to have.
41
40
  *
42
41
  * The second group is platform-written control-plane FILES the platform reads
43
42
  * and writes at the account root every run. Unlike the tool-owned buckets, no
44
- * plugin declares them into the SCHEMA.md allowed-top-level block, so without
45
- * this belt the sweep quarantines them and the feature that reads each one goes
46
- * dark: calendar-availability.json is the booking page's availability config (a
47
- * quarantine froze a live booking page, Task 1877 fix); wa/webchat/telegram
48
- * -channel-bindings.json route each channel's inbound; canonical-webchat-session
49
- * .json pins the webchat session; session-titles.json holds operator renames.
50
- * Each is `join(accountDir, <name>)` in platform code, none is operator data. */
43
+ * plugin declares them into the SCHEMA.md allowed-top-level block: calendar-
44
+ * availability.json is the booking page's availability config (quarantining it
45
+ * froze a live booking page, Task 1877 fix); wa/webchat/telegram-channel-
46
+ * bindings.json route each channel's inbound; canonical-webchat-session.json
47
+ * pins the webchat session; session-titles.json holds operator renames. Each is
48
+ * `join(accountDir, <name>)` in platform code, none is operator data.
49
+ *
50
+ * Since Task 1899 this belt no longer protects anything from being moved,
51
+ * because nothing moves. It keeps the account's own machinery out of the stray
52
+ * count so the standing op=strays line names only what an operator must act on. */
51
53
  const PROTECTED_TOP_LEVEL = new Set([
52
54
  '.git',
53
55
  '.claude',
@@ -79,7 +81,7 @@ const FILE_NAME_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
79
81
  * that an agent authored folders the hard guards could not prevent. Pure
80
82
  * formatter so the emitter and its test share one wording. */
81
83
  export function formatReconcileLine(c) {
82
- return `${TAG} stray-top-level=${c.strayTopLevel} over-deep=${c.overDeep} quarantined=${c.quarantined} bad-name=${c.badName}`;
84
+ return `${TAG} stray-top-level=${c.strayTopLevel} over-deep=${c.overDeep} bad-name=${c.badName}`;
83
85
  }
84
86
  /** Parse the fenced ```allowed-top-level block from an account's SCHEMA.md —
85
87
  * the same structured set fs-schema-guard.sh parses with awk. Returns the
@@ -189,22 +191,12 @@ function countBadNameEntries(accountDir, bucket) {
189
191
  walk(bucketRoot);
190
192
  return count;
191
193
  }
192
- /** Pick a non-colliding destination name inside .quarantine for a stray. */
193
- function quarantineDest(quarantineRoot, name) {
194
- let candidate = join(quarantineRoot, name);
195
- let n = 1;
196
- while (existsSync(candidate)) {
197
- candidate = join(quarantineRoot, `${name}.${n}`);
198
- n++;
199
- }
200
- return candidate;
201
- }
202
194
  /** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
203
195
  * set skips the account (fail open, like the hook — an un-seeded legacy account
204
- * must not have its whole root quarantined). Moves stray top-level entries to
205
- * .quarantine/ with a manifest line; counts over-deep files without moving. */
206
- export function reconcileAccount(accountDir, now) {
207
- const empty = { strayTopLevel: 0, overDeep: 0, quarantined: 0, badName: 0 };
196
+ * must not have its whole root reported as stray). Counts and names stray
197
+ * top-level entries; counts over-deep files and bad names. Moves nothing. */
198
+ export function reconcileAccount(accountDir) {
199
+ const empty = { strayTopLevel: 0, overDeep: 0, badName: 0, strayNames: [] };
208
200
  const schemaPath = join(accountDir, 'SCHEMA.md');
209
201
  if (!existsSync(schemaPath))
210
202
  return empty;
@@ -224,47 +216,36 @@ export function reconcileAccount(accountDir, now) {
224
216
  catch {
225
217
  return empty;
226
218
  }
227
- const strays = rootEntries.filter((name) => !PROTECTED_TOP_LEVEL.has(name) && !allowed.has(name));
228
- let quarantined = 0;
229
- if (strays.length > 0) {
230
- const quarantineRoot = join(accountDir, QUARANTINE_DIR);
231
- const manifestPath = join(quarantineRoot, 'manifest.jsonl');
232
- for (const name of strays) {
233
- try {
234
- mkdirSync(quarantineRoot, { recursive: true });
235
- const dest = quarantineDest(quarantineRoot, name);
236
- renameSync(join(accountDir, name), dest);
237
- const record = {
238
- name,
239
- originalPath: name,
240
- reason: 'top-level',
241
- quarantinePath: relative(accountDir, dest),
242
- movedAt: new Date(now()).toISOString(),
243
- };
244
- appendFileSync(manifestPath, JSON.stringify(record) + '\n');
245
- quarantined++;
246
- }
247
- catch {
248
- // Move failed (permissions, race). Counted as a detected stray below,
249
- // not as quarantined — the summary line stays honest.
250
- }
251
- }
252
- }
219
+ // A dot-prefixed top-level entry is tool/system state, never operator data —
220
+ // the same judgement countBadNameEntries applies when it skips dotfiles. The
221
+ // stray check must agree, or it reports .remember/.superpowers/.wrangler on
222
+ // every run (Task 1891). .git/.claude/.quarantine stay covered by
223
+ // PROTECTED_TOP_LEVEL regardless.
224
+ const strays = rootEntries.filter((name) => !name.startsWith('.') && !PROTECTED_TOP_LEVEL.has(name) && !allowed.has(name));
253
225
  let overDeep = 0;
254
226
  let badName = 0;
255
227
  for (const bucket of DEPTH_CHECKED_BUCKETS) {
256
228
  overDeep += countOverDeepFiles(accountDir, bucket);
257
229
  badName += countBadNameEntries(accountDir, bucket);
258
230
  }
259
- return { strayTopLevel: strays.length, overDeep, quarantined, badName };
231
+ return { strayTopLevel: strays.length, overDeep, badName, strayNames: strays };
260
232
  }
261
- /** One reconcile pass over every account under the accounts root, then emit the
233
+ /** One reconcile pass over every account under the accounts root: one op=strays
234
+ * line naming the unaccounted-for entries of each account that has any, then the
262
235
  * single summary line. An account is any child dir with an account.json (the
263
236
  * account marker the census uses). Per-account errors are swallowed so one bad
264
- * account never aborts the run. */
237
+ * account never aborts the run.
238
+ *
239
+ * The op=strays line is the standing signal. When a feature that reads an
240
+ * account-root file goes dark, seeing that file named here says the schema does
241
+ * not account for it and it is still on disk, so declare it. The absence of its
242
+ * name reads as "gone" only for a file that could have been named at all: an
243
+ * entry in PROTECTED_TOP_LEVEL, a dot-prefixed entry, and every entry of an
244
+ * account whose SCHEMA.md is missing or has an empty allowed block are never
245
+ * named whether they are present or not. Check the account root itself before
246
+ * concluding a file is gone. */
265
247
  export function runReconcile(deps) {
266
- const now = deps.now ?? (() => Date.now());
267
- const total = { strayTopLevel: 0, overDeep: 0, quarantined: 0, badName: 0 };
248
+ const total = { strayTopLevel: 0, overDeep: 0, badName: 0 };
268
249
  let entries;
269
250
  try {
270
251
  entries = readdirSync(deps.accountsRoot);
@@ -278,10 +259,12 @@ export function runReconcile(deps) {
278
259
  if (!existsSync(join(accountDir, 'account.json')))
279
260
  continue;
280
261
  try {
281
- const c = reconcileAccount(accountDir, now);
262
+ const c = reconcileAccount(accountDir);
263
+ if (c.strayNames.length > 0) {
264
+ deps.logger(`${TAG} op=strays account=${entry} names=${c.strayNames.join(',')}`);
265
+ }
282
266
  total.strayTopLevel += c.strayTopLevel;
283
267
  total.overDeep += c.overDeep;
284
- total.quarantined += c.quarantined;
285
268
  total.badName += c.badName;
286
269
  }
287
270
  catch {