@rubytech/create-maxy 1.0.884 → 1.0.886

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.
@@ -83,7 +83,7 @@ Premium plugins are one-off purchases that grant permanent ownership. They are n
83
83
 
84
84
  Some premium plugins are **bundles** — a single purchase that delivers multiple sub-plugins, each independently activatable. For example, Real Agency delivers 11 sub-plugins covering different aspects of estate agency work. You can enable all of them or just the ones you need (e.g., "Enable estate-sales" for just the sales skills). Enabling or disabling individual sub-plugins does not affect the others.
85
85
 
86
- When a bundle is delivered (automatically at session start, or explicitly via `premium-deliver`), every sub-plugin in its manifest is enabled by default — you don't need to enable each one by hand. Sub-plugins you don't want active can be turned off individually with "disable <name>". Standalone premium plugins behave the same way: delivery implies enablement. If you ask {{productName}} about a tool from a plugin you haven't purchased, {{productName}} responds with a structured `<tool-surface-error>` envelope naming the missing plugin and the remedy, rather than improvising with a generic alternative.
86
+ Every admin session start reconciles your `enabledPlugins` against what is actually on disk: any purchased sub-plugin present in `platform/plugins/` is enabled, so you don't need to enable each one by hand. This holds whether the bundle was delivered just now (fresh purchase via `premium-deliver`) or has been on disk from a prior install. Sub-plugins you don't want active can be turned off individually with "disable <name>". Standalone premium plugins behave the same way: presence on disk implies enablement. If you ask {{productName}} about a tool from a plugin you haven't purchased, {{productName}} responds with a structured `<tool-surface-error>` envelope naming the missing plugin and the remedy, rather than improvising with a generic alternative.
87
87
 
88
88
  **Public agent embedding:** Premium plugins marked as public-eligible have their full content (skills and reference knowledge) embedded in public agent prompts. This means a public agent for a Real Agency member can handle buyer enquiries, book viewings, deliver coaching content, and onboard new applicants — all powered by the premium plugin's domain knowledge. Plugins marked admin-only (listings, vendors, leads, business) are only available to the account owner's admin agent.
89
89
 
@@ -73,7 +73,7 @@ tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
73
73
  **Agent searches the filesystem after uploading a zip.** If you uploaded a zip and the agent burns several turns running `find` / `Glob` instead of unzipping, that is the symptom of the recovery-retry attachment-context regression (now closed by the recovery context preservation contract in `.docs/agents.md`). Greppable confirmation is the `[context-overflow-recovery] retry … attachmentsCarried=<n>` line in the conversation stream log. If you see `[context-overflow-recovery] WARN attachment-context-lost`, the regression has returned — surface to support.
74
74
 
75
75
 
76
- **A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-conversation stream log (`logs/claude-agent-stream-<conversationId>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
76
+ **A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-session stream log (`logs/claude-agent-stream-<sessionKey>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
77
77
  **Wrong Claude account answering on a multi-brand device.** On a host running both Maxy and Real Agent, each brand's admin agent reads its own `~/${brand.configDir}/.claude/.credentials.json`; there is no longer a shared `~/.claude/` thrashing them against one another. If a brand reports auth failures or appears to be operating against the wrong subscription, check three things:
78
78
  1. `grep "\[claude-auth\] init" ~/.${brand}/logs/server.log | tail -1` — the resolved path must end with `~/.${brand}/.claude/.credentials.json`. If a `[claude-auth] WARN cross-brand-path-detected` line is present, the runtime is still pointing at `~/.claude/`; the brand main service did not pick up the `Environment=CLAUDE_CONFIG_DIR=` setting (re-run the brand installer to refresh the unit file).
79
79
  2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
@@ -1,19 +1,18 @@
1
1
  #!/usr/bin/env bash
2
- # Task 998bash harness for logs-read.sh prefix-match resolution.
2
+ # Task 1006prefix-match harness for logs-read.sh.
3
3
  #
4
- # Covers the contract from the task brief:
5
- # 1. 8-char prefix resolves full-UUID file (the original bug)
6
- # 2. Multiple matches reason=ambiguous-prefix, exit 1, never picks
7
- # 3. More-specific prefix disambiguates
8
- # 4. Zero matches → reason=file-not-found-in-either-shape with glob patterns
9
- # 5. Full 36-char UUID still resolves (regression boundary)
10
- # 6. Preflush prefix match
11
- # 7. Preflush ambiguity (Pass 1 zero, Pass 2 multiple)
12
- # 8. Every per-conversation type (agent-stream, session, error, public)
13
- # resolves under prefix-match
4
+ # Task 998 introduced 8-char-prefix resolution; Task 1006 collapses the
5
+ # writer's two-shape contract to one (`claude-agent-stream-<sessionKey>.log`).
6
+ # The prefix match still applies, now against a single basename shape.
14
7
  #
15
- # Companion to platform/scripts/logs-read.test.sh (Task 671's two-shape
16
- # resolution harness).
8
+ # Cases:
9
+ # 1. 8-char prefix resolves a full sessionKey-named file.
10
+ # 2. Multiple matches → ambiguous-prefix, exit 1, never picks silently.
11
+ # 3. More-specific prefix disambiguates.
12
+ # 4. Zero matches → exit 1 with file-not-found trailer.
13
+ # 5. Full 36-char sessionKey still resolves (regression boundary).
14
+ # 6. Every per-session type (agent-stream, session, error, public)
15
+ # resolves under prefix-match.
17
16
  set -euo pipefail
18
17
 
19
18
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -26,9 +25,6 @@ fi
26
25
  PASS=0
27
26
  FAIL=0
28
27
 
29
- # logs-read.sh resolves PLATFORM_ROOT from its own location; mirror the
30
- # logs-read.test.sh convention by symlinking the script into an ephemeral
31
- # install tree per case.
32
28
  setup_install_tree() {
33
29
  local root="$1"
34
30
  mkdir -p "$root/platform/scripts"
@@ -58,281 +54,130 @@ run_case() {
58
54
  fi
59
55
  }
60
56
 
61
- # --- Case 1: 8-char prefix resolves a full-UUID file (original bug) ---
62
- case_prefix_resolves_full() {
63
- local root="/tmp/maxy-logs-read-prefix-1-$$"
57
+ case_8char_prefix_resolves_full() {
58
+ local root="/tmp/maxy-prefix-test-1-$$"
64
59
  local script
65
60
  script=$(setup_install_tree "$root")
66
61
  local logdir="$root/data/accounts/acct-test/logs"
67
- local full_uuid="3483269d-c793-4a07-98cc-556d936f2f4d"
68
- local filename="claude-agent-stream-${full_uuid}.log"
69
- echo "prefix-match-sentinel" > "$logdir/$filename"
70
-
71
- local stdout stderr rc=0
72
- stdout=$("$script" "3483269d" system 2>/tmp/logs-read-prefix-stderr-1-$$) || rc=$?
73
- stderr=$(cat /tmp/logs-read-prefix-stderr-1-$$)
74
- rm -f /tmp/logs-read-prefix-stderr-1-$$
62
+ local sk="abcd1234-5678-90ab-cdef-1234567890ab"
63
+ echo "sentinel-1" > "$logdir/claude-agent-stream-${sk}.log"
75
64
 
65
+ local rc=0
66
+ local stdout
67
+ stdout=$("$script" "abcd1234" agent-stream 2>/dev/null) || rc=$?
76
68
  cleanup_install_tree "$root"
77
69
 
78
- if [[ $rc -ne 0 ]]; then echo " expected exit 0, got $rc"; return 1; fi
79
- if [[ "$stdout" != *"prefix-match-sentinel"* ]]; then
80
- echo " stdout missing sentinel: $stdout"; return 1
81
- fi
82
- if [[ "$stderr" != *"matched_shape=full"* ]]; then
83
- echo " stderr missing matched_shape=full: $stderr"; return 1
84
- fi
85
- if [[ "$stdout" != *"$filename"* ]]; then
86
- echo " header missing matched filename: $stdout"; return 1
87
- fi
70
+ [[ $rc -eq 0 ]] || { echo " expected exit 0, got $rc"; return 1; }
71
+ [[ "$stdout" == *"sentinel-1"* ]] || { echo " missing sentinel"; return 1; }
88
72
  return 0
89
73
  }
90
74
 
91
- # --- Case 2: Multiple matches on Pass 1 → ambiguous-prefix, exit 1 ---
92
- case_ambiguous_prefix() {
93
- local root="/tmp/maxy-logs-read-prefix-2-$$"
75
+ case_ambiguous_prefix_refuses() {
76
+ local root="/tmp/maxy-prefix-test-2-$$"
94
77
  local script
95
78
  script=$(setup_install_tree "$root")
96
79
  local logdir="$root/data/accounts/acct-test/logs"
97
- echo "A" > "$logdir/claude-agent-stream-aaaaaaaa-1111-A.log"
98
- echo "B" > "$logdir/claude-agent-stream-aaaaaaaa-2222-B.log"
99
-
100
- local stdout stderr rc=0
101
- stdout=$("$script" "aaaaaaaa" system 2>/tmp/logs-read-prefix-stderr-2-$$) || rc=$?
102
- stderr=$(cat /tmp/logs-read-prefix-stderr-2-$$)
103
- rm -f /tmp/logs-read-prefix-stderr-2-$$
104
-
80
+ echo "a" > "$logdir/claude-agent-stream-abcd1111.log"
81
+ echo "b" > "$logdir/claude-agent-stream-abcd2222.log"
82
+
83
+ local rc=0
84
+ "$script" "abcd" agent-stream >/dev/null 2>/tmp/prefix-stderr-2-$$ || rc=$?
85
+ local stderr
86
+ stderr=$(cat /tmp/prefix-stderr-2-$$)
87
+ rm -f /tmp/prefix-stderr-2-$$
105
88
  cleanup_install_tree "$root"
106
89
 
107
- if [[ $rc -ne 1 ]]; then echo " expected exit 1, got $rc"; return 1; fi
108
- if [[ "$stderr" != *"reason=ambiguous-prefix"* ]]; then
109
- echo " stderr missing reason=ambiguous-prefix: $stderr"; return 1
110
- fi
111
- if [[ "$stderr" != *"matches=2"* ]]; then
112
- echo " stderr missing matches=2: $stderr"; return 1
113
- fi
114
- if [[ "$stderr" != *"claude-agent-stream-aaaaaaaa-1111-A.log"* ]]; then
115
- echo " stderr missing candidate A: $stderr"; return 1
116
- fi
117
- if [[ "$stderr" != *"claude-agent-stream-aaaaaaaa-2222-B.log"* ]]; then
118
- echo " stderr missing candidate B: $stderr"; return 1
119
- fi
120
- # Body must NOT contain either sentinel — refusal means no body emitted.
121
- if [[ -n "$stdout" ]]; then
122
- echo " stdout non-empty on ambiguous refusal: $stdout"; return 1
123
- fi
90
+ [[ $rc -eq 1 ]] || { echo " expected exit 1, got $rc"; return 1; }
91
+ [[ "$stderr" == *"ambiguous-prefix"* ]] || { echo " stderr missing ambiguous-prefix: $stderr"; return 1; }
124
92
  return 0
125
93
  }
126
94
 
127
- # --- Case 3: more-specific prefix disambiguates ---
128
- case_more_specific_disambiguates() {
129
- local root="/tmp/maxy-logs-read-prefix-3-$$"
95
+ case_more_specific_prefix_resolves() {
96
+ local root="/tmp/maxy-prefix-test-3-$$"
130
97
  local script
131
98
  script=$(setup_install_tree "$root")
132
99
  local logdir="$root/data/accounts/acct-test/logs"
133
- echo "ONE" > "$logdir/claude-agent-stream-aaaaaaaa-1111-A.log"
134
- echo "TWO" > "$logdir/claude-agent-stream-aaaaaaaa-2222-B.log"
135
-
136
- local stdout stderr rc=0
137
- stdout=$("$script" "aaaaaaaa-1111" system 2>/tmp/logs-read-prefix-stderr-3-$$) || rc=$?
138
- stderr=$(cat /tmp/logs-read-prefix-stderr-3-$$)
139
- rm -f /tmp/logs-read-prefix-stderr-3-$$
100
+ echo "a" > "$logdir/claude-agent-stream-abcd1111.log"
101
+ echo "b" > "$logdir/claude-agent-stream-abcd2222.log"
140
102
 
103
+ local rc=0
104
+ local stdout
105
+ stdout=$("$script" "abcd1111" agent-stream 2>/dev/null) || rc=$?
141
106
  cleanup_install_tree "$root"
142
107
 
143
- if [[ $rc -ne 0 ]]; then echo " expected exit 0, got $rc"; return 1; fi
144
- if [[ "$stdout" != *"ONE"* ]]; then echo " stdout missing ONE: $stdout"; return 1; fi
145
- if [[ "$stdout" == *"TWO"* ]]; then echo " stdout contained TWO: $stdout"; return 1; fi
146
- if [[ "$stderr" != *"matched_shape=full"* ]]; then
147
- echo " stderr missing matched_shape=full: $stderr"; return 1
148
- fi
108
+ [[ $rc -eq 0 ]] || { echo " expected exit 0, got $rc"; return 1; }
109
+ [[ "$stdout" == *"a"* ]] || { echo " missing sentinel"; return 1; }
149
110
  return 0
150
111
  }
151
112
 
152
- # --- Case 4: zero matches → tried=[<glob>, <glob>] file-not-found ---
153
- case_zero_matches() {
154
- local root="/tmp/maxy-logs-read-prefix-4-$$"
113
+ case_zero_match_miss() {
114
+ local root="/tmp/maxy-prefix-test-4-$$"
155
115
  local script
156
116
  script=$(setup_install_tree "$root")
157
117
 
158
- local stdout stderr rc=0
159
- stdout=$("$script" "xxxxxxxx" system 2>/tmp/logs-read-prefix-stderr-4-$$) || rc=$?
160
- stderr=$(cat /tmp/logs-read-prefix-stderr-4-$$)
161
- rm -f /tmp/logs-read-prefix-stderr-4-$$
162
-
118
+ local rc=0
119
+ "$script" "nomatch1" agent-stream >/dev/null 2>/tmp/prefix-stderr-4-$$ || rc=$?
120
+ local stderr
121
+ stderr=$(cat /tmp/prefix-stderr-4-$$)
122
+ rm -f /tmp/prefix-stderr-4-$$
163
123
  cleanup_install_tree "$root"
164
124
 
165
- if [[ $rc -ne 1 ]]; then echo " expected exit 1, got $rc"; return 1; fi
166
- if [[ "$stderr" != *"reason=file-not-found-in-either-shape"* ]]; then
167
- echo " stderr missing reason=file-not-found-in-either-shape: $stderr"; return 1
168
- fi
169
- if [[ "$stderr" != *"tried=[claude-agent-stream-xxxxxxxx*.log, claude-agent-stream-preflush-xxxxxxxx*.log]"* ]]; then
170
- echo " stderr missing tried=[<glob>, <glob>]: $stderr"; return 1
171
- fi
125
+ [[ $rc -eq 1 ]] || { echo " expected exit 1, got $rc"; return 1; }
126
+ [[ "$stderr" == *"file-not-found"* ]] || { echo " stderr missing file-not-found"; return 1; }
172
127
  return 0
173
128
  }
174
129
 
175
- # --- Case 5: full 36-char UUID still resolves (regression boundary) ---
176
- case_full_uuid_regression() {
177
- local root="/tmp/maxy-logs-read-prefix-5-$$"
130
+ case_full_sessionkey_resolves() {
131
+ local root="/tmp/maxy-prefix-test-5-$$"
178
132
  local script
179
133
  script=$(setup_install_tree "$root")
180
134
  local logdir="$root/data/accounts/acct-test/logs"
181
- local full_uuid="3483269d-c793-4a07-98cc-556d936f2f4d"
182
- local filename="claude-agent-stream-${full_uuid}.log"
183
- echo "regression-sentinel" > "$logdir/$filename"
184
-
185
- local stdout stderr rc=0
186
- stdout=$("$script" "$full_uuid" system 2>/tmp/logs-read-prefix-stderr-5-$$) || rc=$?
187
- stderr=$(cat /tmp/logs-read-prefix-stderr-5-$$)
188
- rm -f /tmp/logs-read-prefix-stderr-5-$$
135
+ local sk="11111111-2222-3333-4444-555555555555"
136
+ echo "sentinel-5" > "$logdir/claude-agent-stream-${sk}.log"
189
137
 
138
+ local rc=0
139
+ local stdout
140
+ stdout=$("$script" "$sk" agent-stream 2>/dev/null) || rc=$?
190
141
  cleanup_install_tree "$root"
191
142
 
192
- if [[ $rc -ne 0 ]]; then echo " expected exit 0, got $rc"; return 1; fi
193
- if [[ "$stdout" != *"regression-sentinel"* ]]; then
194
- echo " stdout missing sentinel: $stdout"; return 1
195
- fi
196
- if [[ "$stderr" != *"matched_shape=full"* ]]; then
197
- echo " stderr missing matched_shape=full: $stderr"; return 1
198
- fi
143
+ [[ $rc -eq 0 ]] || { echo " expected exit 0, got $rc"; return 1; }
144
+ [[ "$stdout" == *"sentinel-5"* ]] || { echo " missing sentinel"; return 1; }
199
145
  return 0
200
146
  }
201
147
 
202
- # --- Case 6: preflush-only file, prefix-match (Pass 2) ---
203
- case_preflush_prefix_match() {
204
- local root="/tmp/maxy-logs-read-prefix-6-$$"
148
+ case_every_per_session_type() {
149
+ local root="/tmp/maxy-prefix-test-6-$$"
205
150
  local script
206
151
  script=$(setup_install_tree "$root")
207
152
  local logdir="$root/data/accounts/acct-test/logs"
208
- # Preflush filename uses sessionKey:0:12; mimic with a 12-char slice.
209
- echo "preflush-prefix-sentinel" > "$logdir/claude-agent-stream-preflush-bbbbbbbb-ccc.log"
210
-
211
- local stdout stderr rc=0
212
- stdout=$("$script" "bbbbbbbb" system 2>/tmp/logs-read-prefix-stderr-6-$$) || rc=$?
213
- stderr=$(cat /tmp/logs-read-prefix-stderr-6-$$)
214
- rm -f /tmp/logs-read-prefix-stderr-6-$$
215
-
216
- cleanup_install_tree "$root"
217
-
218
- if [[ $rc -ne 0 ]]; then echo " expected exit 0, got $rc"; return 1; fi
219
- if [[ "$stdout" != *"preflush-prefix-sentinel"* ]]; then
220
- echo " stdout missing sentinel: $stdout"; return 1
221
- fi
222
- if [[ "$stderr" != *"matched_shape=preflush"* ]]; then
223
- echo " stderr missing matched_shape=preflush: $stderr"; return 1
224
- fi
225
- return 0
226
- }
227
-
228
- # --- Case 7: preflush ambiguity (Pass 1 zero, Pass 2 multiple) ---
229
- case_preflush_ambiguity() {
230
- local root="/tmp/maxy-logs-read-prefix-7-$$"
231
- local script
232
- script=$(setup_install_tree "$root")
233
- local logdir="$root/data/accounts/acct-test/logs"
234
- echo "P1" > "$logdir/claude-agent-stream-preflush-cccccccc-111.log"
235
- echo "P2" > "$logdir/claude-agent-stream-preflush-cccccccc-222.log"
236
-
237
- local stdout stderr rc=0
238
- stdout=$("$script" "cccccccc" system 2>/tmp/logs-read-prefix-stderr-7-$$) || rc=$?
239
- stderr=$(cat /tmp/logs-read-prefix-stderr-7-$$)
240
- rm -f /tmp/logs-read-prefix-stderr-7-$$
241
-
242
- cleanup_install_tree "$root"
243
-
244
- if [[ $rc -ne 1 ]]; then echo " expected exit 1, got $rc"; return 1; fi
245
- if [[ "$stderr" != *"reason=ambiguous-prefix"* ]]; then
246
- echo " stderr missing reason=ambiguous-prefix: $stderr"; return 1
247
- fi
248
- if [[ "$stderr" != *"matches=2"* ]]; then
249
- echo " stderr missing matches=2: $stderr"; return 1
250
- fi
251
- if [[ "$stderr" != *"claude-agent-stream-preflush-cccccccc-111.log"* ]]; then
252
- echo " stderr missing preflush candidate 1: $stderr"; return 1
253
- fi
254
- if [[ "$stderr" != *"claude-agent-stream-preflush-cccccccc-222.log"* ]]; then
255
- echo " stderr missing preflush candidate 2: $stderr"; return 1
256
- fi
257
- return 0
258
- }
259
-
260
- # --- Case 8: every per-conversation type resolves under prefix-match ---
261
- # Parameterised over the four prefix_for_type values:
262
- # agent-stream → claude-agent-stream-
263
- # session → sse-events-
264
- # error → claude-agent-stderr-
265
- # public → public-agent-stream-
266
- case_all_types_prefix_match() {
267
- local root="/tmp/maxy-logs-read-prefix-8-$$"
268
- local script
269
- script=$(setup_install_tree "$root")
270
- local logdir="$root/data/accounts/acct-test/logs"
271
- local full_uuid="7d49ef21-1234-4567-89ab-cdef01234567"
272
-
273
- echo "sentinel-agent-stream" > "$logdir/claude-agent-stream-${full_uuid}.log"
274
- echo "sentinel-session" > "$logdir/sse-events-${full_uuid}.log"
275
- echo "sentinel-error" > "$logdir/claude-agent-stderr-${full_uuid}.log"
276
- echo "sentinel-public" > "$logdir/public-agent-stream-${full_uuid}.log"
277
-
278
- local pairs=(
279
- "agent-stream|sentinel-agent-stream"
280
- "session|sentinel-session"
281
- "error|sentinel-error"
282
- "public|sentinel-public"
283
- )
284
- local fail=0
285
- local pair t expected_sentinel
286
- for pair in "${pairs[@]}"; do
287
- t="${pair%%|*}"
288
- expected_sentinel="${pair##*|}"
289
- local stdout stderr rc=0
290
- stdout=$("$script" "7d49ef21" "$t" 2>/tmp/logs-read-prefix-stderr-8-$$) || rc=$?
291
- stderr=$(cat /tmp/logs-read-prefix-stderr-8-$$)
292
- rm -f /tmp/logs-read-prefix-stderr-8-$$
293
- if [[ $rc -ne 0 ]]; then echo " [$t] expected exit 0, got $rc — stderr: $stderr"; fail=1; continue; fi
294
- if [[ "$stdout" != *"$expected_sentinel"* ]]; then
295
- echo " [$t] missing sentinel '$expected_sentinel': $stdout"; fail=1
296
- fi
297
- if [[ "$stderr" != *"matched_shape=full"* ]]; then
298
- echo " [$t] stderr missing matched_shape=full: $stderr"; fail=1
299
- fi
153
+ local sk="abcdef00-0000-0000-0000-000000000000"
154
+ echo "stream" > "$logdir/claude-agent-stream-${sk}.log"
155
+ echo "err" > "$logdir/claude-agent-stderr-${sk}.log"
156
+ echo "sse" > "$logdir/sse-events-${sk}.log"
157
+ echo "pub" > "$logdir/public-agent-stream-${sk}.log"
158
+
159
+ local ok=1
160
+ for type in agent-stream error session public; do
161
+ local out
162
+ out=$("$script" "abcdef00" "$type" 2>/dev/null) || ok=0
163
+ case "$type" in
164
+ agent-stream) [[ "$out" == *"stream"* ]] || ok=0 ;;
165
+ error) [[ "$out" == *"err"* ]] || ok=0 ;;
166
+ session) [[ "$out" == *"sse"* ]] || ok=0 ;;
167
+ public) [[ "$out" == *"pub"* ]] || ok=0 ;;
168
+ esac
300
169
  done
301
-
302
- cleanup_install_tree "$root"
303
- [[ $fail -eq 0 ]]
304
- }
305
-
306
- # --- Case 9: conv_id with shell metacharacters is rejected ---
307
- case_metacharacter_rejected() {
308
- local root="/tmp/maxy-logs-read-prefix-9-$$"
309
- local script
310
- script=$(setup_install_tree "$root")
311
-
312
- local stdout stderr rc=0
313
- # Use a backslash-escaped wildcard to feed it as a literal argument.
314
- stdout=$("$script" "ab*cd" system 2>/tmp/logs-read-prefix-stderr-9-$$) || rc=$?
315
- stderr=$(cat /tmp/logs-read-prefix-stderr-9-$$)
316
- rm -f /tmp/logs-read-prefix-stderr-9-$$
317
-
318
170
  cleanup_install_tree "$root"
319
-
320
- if [[ $rc -ne 2 ]]; then echo " expected exit 2 (usage), got $rc"; return 1; fi
321
- if [[ "$stderr" != *"invalid characters"* ]]; then
322
- echo " stderr missing invalid-characters guard: $stderr"; return 1
323
- fi
171
+ [[ $ok -eq 1 ]] || { echo " one or more per-session types failed to resolve"; return 1; }
324
172
  return 0
325
173
  }
326
174
 
327
- run_case "8-char prefix resolves full-UUID file" case_prefix_resolves_full
328
- run_case "ambiguous prefix refused, candidates listed" case_ambiguous_prefix
329
- run_case "more-specific prefix disambiguates" case_more_specific_disambiguates
330
- run_case "zero matches → file-not-found-in-either-shape" case_zero_matches
331
- run_case "full 36-char UUID still resolves" case_full_uuid_regression
332
- run_case "preflush file resolves by prefix" case_preflush_prefix_match
333
- run_case "preflush ambiguity refused" case_preflush_ambiguity
334
- run_case "every per-conversation type prefix-matches" case_all_types_prefix_match
335
- run_case "shell metacharacters rejected" case_metacharacter_rejected
175
+ run_case "8-char prefix resolves full sessionKey file" case_8char_prefix_resolves_full
176
+ run_case "ambiguous prefix refuses to pick" case_ambiguous_prefix_refuses
177
+ run_case "more-specific prefix disambiguates" case_more_specific_prefix_resolves
178
+ run_case "zero matches → file-not-found" case_zero_match_miss
179
+ run_case "full 36-char sessionKey resolves" case_full_sessionkey_resolves
180
+ run_case "every per-session type resolves" case_every_per_session_type
336
181
 
337
182
  echo ""
338
183
  echo "================================================"
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ // Task 1007 build-time gate: prevents NEW files from gaining a `conversationId` /
3
+ // `sessionId` reference while the 146-file rename slice is in progress.
4
+ //
5
+ // The legacy identifiers are being collapsed into the single canonical
6
+ // `sessionKey`. This sprint shipped the schema-side load-bearing pieces
7
+ // (backfill, new index, boot-time adherence probe) plus the core lib rename.
8
+ // The mechanical tail (routes, MCP plugin parameters, UI props, WhatsApp,
9
+ // tests, specialist templates) is split across follow-up tasks 1009 and 1010.
10
+ //
11
+ // During the transition window, every file currently containing the legacy
12
+ // identifiers is named in `conversation-id-allowlist.txt`. Each follow-up
13
+ // rename PR removes file paths from the allowlist as those files are migrated.
14
+ // This gate fails if:
15
+ // (a) a file NOT in the allowlist contains any of the four legacy tokens, or
16
+ // (b) the allowlist references a path that no longer exists (stale entry).
17
+ //
18
+ // Once tasks 1009 and 1010 land, the allowlist is empty; the gate then enforces
19
+ // the task's outcome contract (zero hits anywhere outside .tasks/archive/).
20
+
21
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
22
+ import { dirname, join, relative, basename } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
26
+ const REPO_ROOT = join(SCRIPT_DIR, '..', '..')
27
+ const ALLOWLIST_PATH = join(SCRIPT_DIR, 'conversation-id-allowlist.txt')
28
+
29
+ const SCAN_ROOTS = [
30
+ join(REPO_ROOT, 'platform'),
31
+ join(REPO_ROOT, 'packages'),
32
+ join(REPO_ROOT, 'brands'),
33
+ join(REPO_ROOT, '.docs'),
34
+ join(REPO_ROOT, '.claude', 'skills'),
35
+ ]
36
+
37
+ const ALLOWED_EXTS = new Set([
38
+ '.sh', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
39
+ '.md', '.cypher', '.json',
40
+ ])
41
+ const SKIP_DIR_NAMES = new Set([
42
+ 'node_modules', 'dist', 'build', '.next', 'payload',
43
+ ])
44
+ const SKIP_PATH_FRAGMENTS = ['.tasks/archive/']
45
+
46
+ // Whole-word matches only — avoids hits inside longer identifiers like
47
+ // `agentSessionId` (Claude Agent SDK per-process session, intentionally kept)
48
+ // and `conversationIdentity` (ConversationArchive hash key, unrelated).
49
+ const LEGACY_TOKEN_RE = /\b(?:conversationId|sessionId|CONVERSATION_ID|SESSION_ID)\b/
50
+
51
+ // Comment-line skip — matches the convention in check-no-task-id-leaks.mjs.
52
+ // Comments that *describe* the legacy field (inside the migration code itself,
53
+ // or inline docstrings explaining the rename) are not new executing code
54
+ // introducing the token, so they don't trip the gate.
55
+ function isCommentLine(line) {
56
+ const trimmed = line.replace(/^\s+/, '')
57
+ if (trimmed.startsWith('#')) return true
58
+ if (trimmed.startsWith('//')) return true
59
+ if (trimmed.startsWith('/*')) return true
60
+ if (trimmed.startsWith('*')) return true
61
+ if (trimmed.startsWith('{/*')) return true
62
+ return false
63
+ }
64
+
65
+ function loadAllowlist() {
66
+ if (!existsSync(ALLOWLIST_PATH)) return new Set()
67
+ const text = readFileSync(ALLOWLIST_PATH, 'utf-8')
68
+ return new Set(
69
+ text.split('\n')
70
+ .map(l => l.trim())
71
+ .filter(l => l && !l.startsWith('#'))
72
+ )
73
+ }
74
+
75
+ function shouldScanFile(filePath) {
76
+ const name = basename(filePath)
77
+ const ext = name.includes('.') ? '.' + name.split('.').pop() : ''
78
+ if (!ALLOWED_EXTS.has(ext)) return false
79
+ const rel = relative(REPO_ROOT, filePath)
80
+ for (const frag of SKIP_PATH_FRAGMENTS) {
81
+ if (rel.includes(frag)) return false
82
+ }
83
+ return true
84
+ }
85
+
86
+ function* walk(root) {
87
+ let entries
88
+ try { entries = readdirSync(root) } catch { return }
89
+ for (const name of entries) {
90
+ if (SKIP_DIR_NAMES.has(name)) continue
91
+ const full = join(root, name)
92
+ let st
93
+ try { st = statSync(full) } catch { continue }
94
+ if (st.isDirectory()) {
95
+ yield* walk(full)
96
+ } else if (st.isFile()) {
97
+ yield full
98
+ }
99
+ }
100
+ }
101
+
102
+ const allowlist = loadAllowlist()
103
+ const newLeaks = []
104
+ const hitFiles = new Set()
105
+
106
+ for (const root of SCAN_ROOTS) {
107
+ if (!existsSync(root)) continue
108
+ for (const file of walk(root)) {
109
+ if (!shouldScanFile(file)) continue
110
+ let content
111
+ try { content = readFileSync(file, 'utf-8') } catch { continue }
112
+ if (!LEGACY_TOKEN_RE.test(content)) continue
113
+ const rel = relative(REPO_ROOT, file)
114
+ const lines = content.split('\n')
115
+ // For non-allowlisted files: only flag hits in non-comment lines. For
116
+ // allowlisted files: any hit counts (so the allowlist accurately tracks
117
+ // the surface still using the legacy name, comments and code alike).
118
+ if (allowlist.has(rel)) {
119
+ hitFiles.add(rel)
120
+ continue
121
+ }
122
+ let firstLine = 0, firstText = ''
123
+ for (let i = 0; i < lines.length; i++) {
124
+ if (!LEGACY_TOKEN_RE.test(lines[i])) continue
125
+ if (isCommentLine(lines[i])) continue
126
+ firstLine = i + 1
127
+ firstText = lines[i].trim().slice(0, 120)
128
+ break
129
+ }
130
+ if (firstLine > 0) {
131
+ newLeaks.push({ file: rel, line: firstLine, content: firstText })
132
+ }
133
+ }
134
+ }
135
+
136
+ // Stale allowlist entries: files in the allowlist that no longer have a hit
137
+ // (renamed already, or no longer exist) — remove them.
138
+ const staleAllowlistEntries = [...allowlist].filter(p => !hitFiles.has(p))
139
+
140
+ let failed = false
141
+
142
+ if (newLeaks.length > 0) {
143
+ failed = true
144
+ console.error(`check-no-conversation-id-leaks: ${newLeaks.length} NEW file(s) introduced legacy identifier(s):`)
145
+ for (const leak of newLeaks) {
146
+ console.error(` ${leak.file}:${leak.line}: ${leak.content}`)
147
+ }
148
+ console.error('')
149
+ console.error('The single canonical identifier is `sessionKey`. New code must use it.')
150
+ console.error('Legacy tokens: conversationId, sessionId, CONVERSATION_ID, SESSION_ID.')
151
+ console.error('Follow-up tasks 1009 + 1010 are migrating existing files off the allowlist.')
152
+ }
153
+
154
+ if (staleAllowlistEntries.length > 0) {
155
+ failed = true
156
+ console.error('')
157
+ console.error(`check-no-conversation-id-leaks: ${staleAllowlistEntries.length} stale allowlist entry(ies) — remove from platform/scripts/conversation-id-allowlist.txt:`)
158
+ for (const entry of staleAllowlistEntries) {
159
+ console.error(` ${entry}`)
160
+ }
161
+ }
162
+
163
+ if (failed) process.exit(1)
164
+
165
+ console.error(`check-no-conversation-id-leaks: ok (${allowlist.size} files in transition allowlist; 0 new leaks)`)