@rubytech/create-maxy-code 0.1.112 → 0.1.114

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 (56) hide show
  1. package/dist/__tests__/brew-install.test.js +10 -0
  2. package/dist/__tests__/brew-resolve.test.js +35 -0
  3. package/dist/__tests__/launchd-plist.test.js +46 -0
  4. package/dist/__tests__/macos-darwin-branch.test.js +85 -0
  5. package/dist/brew-install.js +5 -0
  6. package/dist/index.js +26 -6
  7. package/package.json +1 -1
  8. package/payload/platform/neo4j/schema.cypher +7 -2
  9. package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +14 -7
  10. package/payload/platform/plugins/cloudflare/references/manual-setup.md +105 -0
  11. package/payload/platform/plugins/docs/references/deployment.md +22 -4
  12. package/payload/platform/plugins/docs/references/internals.md +10 -26
  13. package/payload/platform/plugins/memory/PLUGIN.md +2 -1
  14. package/payload/platform/plugins/memory/mcp/dist/index.js +35 -0
  15. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/memory/mcp/dist/tools/image-fetch.d.ts +15 -0
  17. package/payload/platform/plugins/memory/mcp/dist/tools/image-fetch.d.ts.map +1 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/image-fetch.js +64 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/image-fetch.js.map +1 -0
  20. package/payload/platform/plugins/memory/references/schema-estate-agent.md +25 -0
  21. package/payload/platform/plugins/venture-studio/PLUGIN.md +7 -3
  22. package/payload/platform/plugins/venture-studio/bin/scaffold.sh +14 -2
  23. package/payload/platform/plugins/venture-studio/skills/investor-data-room/references/data-room-structure.md +10 -0
  24. package/payload/platform/plugins/venture-studio/skills/prototype-host/SKILL.md +179 -0
  25. package/payload/platform/plugins/venture-studio/skills/prototype-host/references/cloudflared-ingress-edit.md +81 -0
  26. package/payload/platform/plugins/venture-studio/skills/prototype-host/references/scaffold-frameworks.md +60 -0
  27. package/payload/platform/plugins/venture-studio/skills/prototype-host/references/systemd-user-service.md +104 -0
  28. package/payload/platform/plugins/whatsapp/.claude-plugin/plugin.json +9 -0
  29. package/payload/platform/plugins/whatsapp/PLUGIN.md +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/brand-foreign-filter.d.ts +30 -0
  31. package/payload/platform/services/claude-session-manager/dist/brand-foreign-filter.d.ts.map +1 -0
  32. package/payload/platform/services/claude-session-manager/dist/brand-foreign-filter.js +86 -0
  33. package/payload/platform/services/claude-session-manager/dist/brand-foreign-filter.js.map +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/index.js +51 -9
  35. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +35 -4
  38. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +7 -0
  40. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +7 -0
  42. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  43. package/payload/platform/templates/agents/public/IDENTITY.md +11 -1
  44. package/payload/premium-plugins/real-agent/BUNDLE.md +1 -1
  45. package/payload/premium-plugins/real-agent/agents/listing-curator.md +21 -2
  46. package/payload/premium-plugins/real-agent/plugins/buyers/.claude-plugin/plugin.json +1 -1
  47. package/payload/premium-plugins/real-agent/plugins/buyers/PLUGIN.md +3 -2
  48. package/payload/premium-plugins/real-agent/plugins/buyers/skills/property-recommender/SKILL.md +116 -0
  49. package/payload/premium-plugins/venture-studio/PLUGIN.md +7 -3
  50. package/payload/premium-plugins/venture-studio/bin/scaffold.sh +14 -2
  51. package/payload/premium-plugins/venture-studio/skills/investor-data-room/references/data-room-structure.md +10 -0
  52. package/payload/premium-plugins/venture-studio/skills/prototype-host/SKILL.md +179 -0
  53. package/payload/premium-plugins/venture-studio/skills/prototype-host/references/cloudflared-ingress-edit.md +81 -0
  54. package/payload/premium-plugins/venture-studio/skills/prototype-host/references/scaffold-frameworks.md +60 -0
  55. package/payload/premium-plugins/venture-studio/skills/prototype-host/references/systemd-user-service.md +104 -0
  56. package/payload/server/server.js +92 -41
@@ -73,9 +73,14 @@ test("decideBrewInstallPlan: all pkgs already installed → empty install list,
73
73
  ],
74
74
  });
75
75
  assert.deepEqual(plan.toInstall, []);
76
+ // Each pkg emits the legacy `[homebrew]` line plus the uniform-prefix
77
+ // `[create-maxy] brew-formula=… action=…` mirror Task 297 added for the
78
+ // grep recipe.
76
79
  assert.deepEqual(plan.logs, [
77
80
  " [homebrew] node@22 already installed (v22.18.1)",
81
+ " [create-maxy] brew-formula=node@22 action=present",
78
82
  " [homebrew] neo4j already installed (v5.20.0)",
83
+ " [create-maxy] brew-formula=neo4j action=present",
79
84
  ]);
80
85
  });
81
86
  test("decideBrewInstallPlan: some missing → install list contains only missing formulas, install + already logs interleaved", () => {
@@ -89,8 +94,11 @@ test("decideBrewInstallPlan: some missing → install list contains only missing
89
94
  assert.deepEqual(plan.toInstall, ["neo4j", "cloudflared"]);
90
95
  assert.deepEqual(plan.logs, [
91
96
  " [homebrew] node@22 already installed (v22.18.1)",
97
+ " [create-maxy] brew-formula=node@22 action=present",
92
98
  " [homebrew] installing neo4j …",
99
+ " [create-maxy] brew-formula=neo4j action=install",
93
100
  " [homebrew] installing cloudflared …",
101
+ " [create-maxy] brew-formula=cloudflared action=install",
94
102
  ]);
95
103
  });
96
104
  test("decideBrewInstallPlan: all missing → install list = every formula, only install logs", () => {
@@ -103,7 +111,9 @@ test("decideBrewInstallPlan: all missing → install list = every formula, only
103
111
  assert.deepEqual(plan.toInstall, ["node@22", "neo4j"]);
104
112
  assert.deepEqual(plan.logs, [
105
113
  " [homebrew] installing node@22 …",
114
+ " [create-maxy] brew-formula=node@22 action=install",
106
115
  " [homebrew] installing neo4j …",
116
+ " [create-maxy] brew-formula=neo4j action=install",
107
117
  ]);
108
118
  });
109
119
  // ---------------------------------------------------------------------------
@@ -101,3 +101,38 @@ test("DARWIN_ALIASES contains nodejs → node@22 and is the only entry", () => {
101
101
  assert.equal(DARWIN_ALIASES.nodejs, "node@22");
102
102
  assert.equal(Object.keys(DARWIN_ALIASES).length, 1);
103
103
  });
104
+ // ---------------------------------------------------------------------------
105
+ // DARWIN_BASE_DEPS + neo4j shape lock — every brew dep the darwin install
106
+ // branch feeds into installAllBrewPackages must either (a) be a real Homebrew
107
+ // core formula already, or (b) resolve to one via DARWIN_ALIASES. The list is
108
+ // duplicated here from index.ts:installSystemDeps so a future edit of either
109
+ // surface has to keep them in sync.
110
+ // ---------------------------------------------------------------------------
111
+ // Mirrors `DARWIN_BASE_DEPS` in packages/create-maxy-code/src/index.ts. Every
112
+ // entry must be either a real brew formula (pass-through) or an alias.
113
+ const DARWIN_BASE_DEPS_FIXTURE = ["curl", "git", "unzip", "jq", "poppler", "ffmpeg"];
114
+ // `neo4j` and `cloudflared` are passed to installAllBrewPackages from
115
+ // installNeo4j (index.ts:1265) and installCloudflared (index.ts:1728). Both are
116
+ // concrete brew formula names — no alias hop expected.
117
+ const DARWIN_SOLO_DEPS_FIXTURE = ["neo4j", "cloudflared"];
118
+ test("every DARWIN_BASE_DEPS entry passes through the resolver as the brew formula name", () => {
119
+ for (const pkg of DARWIN_BASE_DEPS_FIXTURE) {
120
+ const r = decideBrewResolution({ pkg, brewInstalled: false });
121
+ assert.equal(r.resolved, pkg, `DARWIN_BASE_DEPS entry '${pkg}' must not be aliased`);
122
+ assert.equal(r.log, null, `DARWIN_BASE_DEPS entry '${pkg}' must not emit an alias log line`);
123
+ }
124
+ });
125
+ test("neo4j and cloudflared pass through unchanged — no alias hop", () => {
126
+ for (const pkg of DARWIN_SOLO_DEPS_FIXTURE) {
127
+ const r = decideBrewResolution({ pkg, brewInstalled: false });
128
+ assert.equal(r.resolved, pkg);
129
+ assert.equal(r.log, null);
130
+ }
131
+ });
132
+ test("nodejs alias hop resolves to node@22 for darwin install", () => {
133
+ // The shared-dep call site (index.ts:988 installNodejs) passes "nodejs";
134
+ // this test pins the brew-formula landing for that single alias.
135
+ const r = decideBrewResolution({ pkg: "nodejs", brewInstalled: false });
136
+ assert.equal(r.resolved, "node@22");
137
+ assert.match(r.log ?? "", /node@22/);
138
+ });
@@ -128,6 +128,52 @@ test("renderPlist escapes `&` once, never double-escapes", () => {
128
128
  // child to "" (current dir = launchd's cwd, /), which silently changes
129
129
  // behaviour from "stay where invoked" to "/" — a different bug than absence.
130
130
  // ---------------------------------------------------------------------------
131
+ // ---------------------------------------------------------------------------
132
+ // Brand-stamped labels — both shipping brands must render distinct labels so a
133
+ // co-resident install does not collide on a single LaunchAgent file.
134
+ // `launchdLabel()` in index.ts returns `com.rubytech.${BRAND.hostname}`; this
135
+ // test pins the brand-input → label contract end-to-end against the renderer.
136
+ // ---------------------------------------------------------------------------
137
+ test("renderPlist maxy-code brand fields land in the plist verbatim", () => {
138
+ // BRAND.hostname=maxy-code, configDir=.maxy-code per brands/maxy-code/brand.json.
139
+ // installServiceDarwin builds ProgramArguments=["/bin/bash", wrapperPath],
140
+ // WorkingDirectory=`${INSTALL_DIR}/server`, std{out,err}Path=`<logsDir>/server.log`.
141
+ const xml = renderPlist({
142
+ label: "com.rubytech.maxy-code",
143
+ programArguments: ["/bin/bash", "/Users/alice/.maxy-code/launchd-wrapper.sh"],
144
+ stdoutPath: "/Users/alice/.maxy-code/logs/server.log",
145
+ stderrPath: "/Users/alice/.maxy-code/logs/server.log",
146
+ keepAlive: true,
147
+ runAtLoad: true,
148
+ workingDirectory: "/Users/alice/maxy-code/server",
149
+ });
150
+ assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.maxy-code<\/string>/);
151
+ assert.match(xml, /<string>\/bin\/bash<\/string>/);
152
+ assert.match(xml, /<string>\/Users\/alice\/\.maxy-code\/launchd-wrapper\.sh<\/string>/);
153
+ assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/Users\/alice\/maxy-code\/server<\/string>/);
154
+ assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/Users\/alice\/\.maxy-code\/logs\/server\.log<\/string>/);
155
+ assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
156
+ assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
157
+ });
158
+ test("renderPlist realagent-code brand fields land in the plist verbatim", () => {
159
+ // BRAND.hostname=realagent-code, configDir=.realagent-code per brands/realagent-code/brand.json.
160
+ // The label must differ from the maxy-code label so a co-resident install
161
+ // produces two distinct ~/Library/LaunchAgents/<label>.plist files.
162
+ const xml = renderPlist({
163
+ label: "com.rubytech.realagent-code",
164
+ programArguments: ["/bin/bash", "/Users/alice/.realagent-code/launchd-wrapper.sh"],
165
+ stdoutPath: "/Users/alice/.realagent-code/logs/server.log",
166
+ stderrPath: "/Users/alice/.realagent-code/logs/server.log",
167
+ keepAlive: true,
168
+ runAtLoad: true,
169
+ workingDirectory: "/Users/alice/realagent-code/server",
170
+ });
171
+ assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.realagent-code<\/string>/);
172
+ assert.match(xml, /<string>\/Users\/alice\/\.realagent-code\/launchd-wrapper\.sh<\/string>/);
173
+ assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/Users\/alice\/realagent-code\/server<\/string>/);
174
+ // Label collision guard — the maxy-code label must not appear in a realagent plist.
175
+ assert.ok(!/com\.rubytech\.maxy-code/.test(xml), "realagent plist must not carry maxy-code label");
176
+ });
131
177
  test("renderPlist omits WorkingDirectory key when workingDirectory is absent", () => {
132
178
  const xml = renderPlist({
133
179
  label: "com.rubytech.maxy",
@@ -0,0 +1,85 @@
1
+ // Static-grep contract for the darwin install branches in index.ts.
2
+ //
3
+ // Task 297 audited every `platform === "darwin"` block; this test pins the
4
+ // shape so a future refactor that removes a darwin branch without an explicit
5
+ // reason fails loudly here. The test reads src/index.ts at runtime (the
6
+ // authoritative source) and regex-asserts the per-branch markers — keeping
7
+ // the spec adjacent to the code rather than buried in a doc.
8
+ //
9
+ // Why static-grep, not behaviour: the install branches each spawn (sudo,
10
+ // brew, launchctl), and end-to-end darwin reproduction belongs to the manual
11
+ // smoke path in the task brief. The unit suite's job is to ensure the
12
+ // branch points still exist after a refactor.
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { readFileSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, resolve } from "node:path";
18
+ // dist/__tests__/macos-darwin-branch.test.js → ../../src/index.ts
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const INDEX_TS = resolve(here, "../../src/index.ts");
21
+ const SRC = readFileSync(INDEX_TS, "utf-8");
22
+ test("installSystemDeps has darwin branch that calls setMacosHostnameViaScutil on --hostname and scutil --get LocalHostName without", () => {
23
+ // The hostname block at index.ts:installSystemDeps must:
24
+ // - call setMacosHostnameViaScutil(HOSTNAME_FLAG) when HOSTNAME_FLAG is set
25
+ // - call scutil --get LocalHostName when HOSTNAME_FLAG is absent
26
+ // The two paths are the contract Task 297 locks in.
27
+ assert.match(SRC, /setMacosHostnameViaScutil\(HOSTNAME_FLAG\)/, "scutil-set path on --hostname must call setMacosHostnameViaScutil");
28
+ assert.match(SRC, /scutil["']?,\s*\[["']--get["']\s*,\s*["']LocalHostName["']\]/, "scutil --get LocalHostName must be the no-flag default on darwin");
29
+ assert.match(SRC, /darwin-hostname-mode=scutil-get/, "scutil-get success path must emit observability log");
30
+ assert.match(SRC, /darwin-hostname-mode=scutil-set/, "scutil-set path must emit observability log");
31
+ assert.match(SRC, /darwin-hostname-mode=brand-fallback/, "brand-fallback path must emit observability log");
32
+ });
33
+ test("installSystemDeps darwin branch installs DARWIN_BASE_DEPS via brew", () => {
34
+ // The shared dep set the darwin path feeds to installAllBrewPackages. If
35
+ // this list shrinks or a name changes, brew-resolve.test.ts (DARWIN_BASE_DEPS_FIXTURE)
36
+ // must move in lockstep — the comment in brew-resolve.test.ts says so.
37
+ assert.match(SRC, /DARWIN_BASE_DEPS\s*=\s*\[\s*"curl"\s*,\s*"git"\s*,\s*"unzip"\s*,\s*"jq"\s*,\s*"poppler"\s*,\s*"ffmpeg"\s*\]/);
38
+ assert.match(SRC, /installAllBrewPackages\(DARWIN_BASE_DEPS,\s*logFile\)/);
39
+ });
40
+ test("installCloudflared darwin branch logs the documented skip and brew-installs cloudflared", () => {
41
+ // Task 297 doctrine: skip tunnel provisioning entirely on darwin. The brew
42
+ // install puts the binary on PATH so the operator can opt in to `cloudflared
43
+ // tunnel login` post-install; the explicit log line is the diagnostic
44
+ // anchor for investigators grepping the install log.
45
+ assert.match(SRC, /darwin-cloudflare-skip=true/);
46
+ assert.match(SRC, /installAllBrewPackages\(\["cloudflared"\],\s*logFile\)/);
47
+ // No `cloudflared service install` or `cloudflared tunnel route dns`
48
+ // invocation in the installer surface — those are operator-driven,
49
+ // post-install steps. Match actual argv shapes (string-literal list to
50
+ // spawnSync/shell) rather than prose: argv arrays carry the args as
51
+ // quoted siblings inside `[...]`. Comment text is filtered by requiring
52
+ // a quote character immediately adjacent.
53
+ assert.ok(!/\[\s*["']cloudflared["'][^\]]*["']service["'][^\]]*["']install["']/.test(SRC), "installer must not invoke `cloudflared service install`");
54
+ assert.ok(!/\[\s*["']cloudflared["'][^\]]*["']tunnel["'][^\]]*["']route["'][^\]]*["']dns["']/.test(SRC), "installer must not invoke `cloudflared tunnel route dns`");
55
+ });
56
+ test("installService darwin branch delegates to installServiceDarwin (launchd path, not systemd)", () => {
57
+ // Two contracts: the platform switch routes darwin to installServiceDarwin,
58
+ // and installServiceDarwin writes the plist + bootstraps it via launchctl.
59
+ assert.match(SRC, /platform\s*===\s*"darwin"\s*\)\s*\{\s*installServiceDarwin\(\)/);
60
+ assert.match(SRC, /launchctl["']?,\s*\[["']bootstrap["']/);
61
+ assert.match(SRC, /launchd-plist=\$\{path\}\s+loaded=true/, "successful bootstrap must emit launchd-plist=… loaded=true");
62
+ assert.match(SRC, /launchd-plist=\$\{path\}\s+loaded=false/, "failed bootstrap must emit launchd-plist=… loaded=false");
63
+ });
64
+ test("installNodejs darwin branch brew-installs the `nodejs` alias name (resolver hops to node@22)", () => {
65
+ // The shared call site uses the apt-style `nodejs` name. brew-resolve.ts
66
+ // hops it to `node@22` via DARWIN_ALIASES — the alias is the deliberate
67
+ // wedge so a single dep name works across both backends.
68
+ assert.match(SRC, /platform\s*===\s*"darwin"[\s\S]{0,400}installAllBrewPackages\(\["nodejs"\]/);
69
+ });
70
+ test("installNeo4j darwin branch brew-installs `neo4j` (no alias hop)", () => {
71
+ // `neo4j` is the concrete brew formula name on darwin, no alias needed.
72
+ assert.match(SRC, /platform\s*===\s*"darwin"[\s\S]{0,800}installAllBrewPackages\(\["neo4j"\]/);
73
+ });
74
+ test("chromium binary-path file write skips darwin (Playwright-managed)", () => {
75
+ // writeChromiumBinaryPathFile early-returns on non-linux. The completion URL
76
+ // and VNC stack on darwin use Playwright/brew-managed Chromium instead of
77
+ // the linux per-brand chromium-binary.path file.
78
+ assert.match(SRC, /writeChromiumBinaryPathFile[\s\S]{0,200}process\.platform\s*!==\s*"linux"/);
79
+ });
80
+ test("uninstall darwin path bootsout the LaunchAgent and removes the plist", () => {
81
+ // The uninstall surface lives in uninstall.ts (separate file); this index.ts
82
+ // sanity check just confirms the launchdLabel helper still exists and is
83
+ // brand-keyed — install + uninstall both depend on the same label shape.
84
+ assert.match(SRC, /function launchdLabel\(\):\s*string\s*\{\s*return\s*`com\.rubytech\.\$\{BRAND\.hostname\}`/);
85
+ });
@@ -71,9 +71,14 @@ export function decideBrewInstallPlan(input) {
71
71
  if (p.installed) {
72
72
  const head = p.versions[0] ?? "?";
73
73
  logs.push(` [homebrew] ${p.resolvedFormula} already installed (v${head})`);
74
+ // Uniform-prefix mirror line so the install-log grep recipe in
75
+ // platform/plugins/docs/references/deployment.md (`grep -E '^\[create-maxy\] …'`)
76
+ // covers every brew step alongside platform=, darwin-hostname-mode=, etc.
77
+ logs.push(` [create-maxy] brew-formula=${p.resolvedFormula} action=present`);
74
78
  }
75
79
  else {
76
80
  logs.push(` [homebrew] installing ${p.resolvedFormula} …`);
81
+ logs.push(` [create-maxy] brew-formula=${p.resolvedFormula} action=install`);
77
82
  toInstall.push(p.resolvedFormula);
78
83
  }
79
84
  }
package/dist/index.js CHANGED
@@ -614,6 +614,7 @@ function installSystemDeps() {
614
614
  // otherwise preserve the existing system hostname.
615
615
  if (HOSTNAME_FLAG) {
616
616
  console.log(` Hostname: ${HOSTNAME_FLAG} (from --hostname flag)`);
617
+ logFile(` [create-maxy] darwin-hostname-mode=scutil-set value=${HOSTNAME_FLAG}`);
617
618
  try {
618
619
  setMacosHostnameViaScutil(HOSTNAME_FLAG);
619
620
  DEVICE_HOSTNAME = HOSTNAME_FLAG;
@@ -623,11 +624,25 @@ function installSystemDeps() {
623
624
  }
624
625
  }
625
626
  else {
626
- try {
627
- DEVICE_HOSTNAME = execFileSync("hostname", [], { encoding: "utf-8" }).trim();
628
- console.log(` Hostname: ${DEVICE_HOSTNAME} (preserved no --hostname flag on darwin)`);
627
+ // No flag — read the live LocalHostName so the printed URL matches mDNS
628
+ // resolution (`<LocalHostName>.local`). The `hostname` command on darwin
629
+ // returns the fully-qualified `<name>.local`, which would render the
630
+ // completion URL with a double `.local` suffix; scutil --get returns the
631
+ // bare token. No sudo, no system mutation.
632
+ const scutilGet = spawnSync("scutil", ["--get", "LocalHostName"], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
633
+ const scutilName = ((scutilGet.stdout ?? "").trim());
634
+ if (scutilGet.status === 0 && scutilName.length > 0) {
635
+ DEVICE_HOSTNAME = scutilName;
636
+ console.log(` Hostname: ${DEVICE_HOSTNAME} (from scutil --get LocalHostName — no --hostname flag)`);
637
+ logFile(` [create-maxy] darwin-hostname-mode=scutil-get value=${DEVICE_HOSTNAME}`);
638
+ }
639
+ else {
640
+ // scutil failed or returned empty — fall back to BRAND.hostname so the
641
+ // completion URL at least matches the brand's documented .local name.
642
+ DEVICE_HOSTNAME = BRAND.hostname;
643
+ console.log(` Hostname: ${DEVICE_HOSTNAME} (brand fallback — scutil --get LocalHostName empty)`);
644
+ logFile(` [create-maxy] darwin-hostname-mode=brand-fallback value=${DEVICE_HOSTNAME} scutil-status=${scutilGet.status}`);
629
645
  }
630
- catch { /* fallback to brand — set at declaration */ }
631
646
  }
632
647
  // macOS has no apt analogue for the VNC/WiFi-AP stacks
633
648
  // (kiosk display + hostapd/dnsmasq are Pi-specific's
@@ -1541,8 +1556,11 @@ function installCloudflared() {
1541
1556
  if (platform === "darwin") {
1542
1557
  // Homebrew's `cloudflared` formula tracks the Cloudflare release stream;
1543
1558
  // tunnel-login flow stays unchanged (`feedback_no_api_token_route.md`).
1544
- // `cloudflared service install` is invoked by the launchd supervisor in
1545
- // out of scope here.
1559
+ // `cloudflared service install` and `cloudflared tunnel route dns` are not
1560
+ // invoked by the installer on darwin — public reach is operator-driven
1561
+ // post-install (`cloudflared tunnel login` + follow-up tooling). The brew
1562
+ // install puts the binary on PATH so the operator can opt in later.
1563
+ logFile(` [create-maxy] darwin-cloudflare-skip=true reason="Mac laptop — no public hostname provisioned at install time"`);
1546
1564
  installAllBrewPackages(["cloudflared"], logFile);
1547
1565
  return;
1548
1566
  }
@@ -2592,11 +2610,13 @@ function installServiceDarwin() {
2592
2610
  if (bootstrap.status === 0) {
2593
2611
  console.log(` [launchd] bootstrap ${gui()}/${label} ok`);
2594
2612
  logFile(` [launchd] bootstrap ${gui()}/${label} ok`);
2613
+ logFile(` [create-maxy] launchd-plist=${path} loaded=true`);
2595
2614
  }
2596
2615
  else {
2597
2616
  const stderr = (bootstrap.stderr ?? "").trim();
2598
2617
  console.error(` [launchd] bootstrap returned ${bootstrap.status}: ${stderr}`);
2599
2618
  logFile(` [launchd] bootstrap returned ${bootstrap.status}: ${stderr}`);
2619
+ logFile(` [create-maxy] launchd-plist=${path} loaded=false exit=${bootstrap.status}`);
2600
2620
  throw new Error(`launchctl bootstrap ${gui()} ${path} failed (exit ${bootstrap.status}): ${stderr}`);
2601
2621
  }
2602
2622
  // Wait for the server to come up.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.112",
3
+ "version": "0.1.114",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -792,8 +792,13 @@ FOR (ag:AccessGrant) ON (ag.status);
792
792
  // displayName — operator-facing name from config.json
793
793
  // status — 'active' | 'inactive' | <other> per config.json
794
794
  // model — Anthropic model id used by the public agent
795
- // liveMemory — bool; whether memory-search runs at message time
796
- // knowledgeKeywords string[] (live-search keyword subscriptions)
795
+ // liveMemory — bool; when true, the public PTY spawn includes
796
+ // memory-search in --allowed-tools so the agent can
797
+ // hit the graph at message time (scoped via
798
+ // ALLOWED_SCOPES=public). When false, the agent
799
+ // runs with only its baked KNOWLEDGE.md.
800
+ // knowledgeKeywords — string[] used by update-knowledge to broaden the
801
+ // refresh query alongside operator-tagged nodes
797
802
  // role — always 'agent' (mirrors KnowledgeDocument.role
798
803
  // discriminator; surfaces in BM25 hits)
799
804
  // createdAt — ISO 8601, set on first projection
@@ -41,13 +41,13 @@ Each agent lives at `{accountDir}/agents/{slug}/`:
41
41
 
42
42
  ### liveMemory
43
43
 
44
- When `true`, the platform queries the knowledge graph at runtime per message and appends live results alongside baked KNOWLEDGE.md. When `false` (default), the agent uses only the static KNOWLEDGE.md snapshot.
44
+ Controls whether the public agent gets `memory-search` at message time. When `true`, the per-spawn `--allowed-tools` allowlist includes `memory-search` so the agent can hit the graph during a turn scoped to `scope: "public"` nodes via `ALLOWED_SCOPES=public` on the spawn env. When `false` (default), the agent runs with only the baked KNOWLEDGE.md and has no graph access mid-conversation.
45
45
 
46
- Present during creation and editing as a yes/no choice. Explain the trade-off: live memory adds a small latency per message (~1-2s) but keeps the agent current with graph changes. For agents with static knowledge (pricing, FAQs), leave off. For agents that need to reflect recent graph updates (dynamic catalogues, changing availability), enable it.
46
+ Present during creation and editing as a yes/no choice. Explain the trade-off: live memory adds a small latency per turn when the agent decides to search but keeps the agent current with graph changes (new listings, fresh availability). For agents whose knowledge is fully covered by KNOWLEDGE.md (pricing, FAQs), leave off. For agents that need to surface graph content created after the agent was deployed (catalogue items, schedule slots), enable it.
47
47
 
48
48
  ### knowledgeKeywords
49
49
 
50
- Topic keywords the agent subscribes to. When the graph is searched for this agent (at runtime via `liveMemory` or during a `update-knowledge` refresh), nodes matching these keywords are included automatically — even if they are not directly tagged for the agent.
50
+ Topic keywords the agent subscribes to. During an `update-knowledge` refresh, nodes matching these keywords are included automatically — even if they are not directly tagged for the agent.
51
51
 
52
52
  Maximum 5 keywords. Present during creation as a free-text input. Examples: `["climate science", "sustainability"]`, `["pricing", "services"]`.
53
53
 
@@ -113,7 +113,14 @@ SOUL.md answers "what does this agent feel like to talk to?" — tone, warmth, f
113
113
 
114
114
  ## KNOWLEDGE.md Population
115
115
 
116
- KNOWLEDGE.md is the public agent's only source of factual knowledge. Public agents run via the direct Anthropic API with no runtime access to the graph no `memory-search`, no filesystem, no MCP tools. Once KNOWLEDGE.md exists, the server skips runtime memory-search entirely. Every gap in KNOWLEDGE.md is a question the public agent cannot answer.
116
+ KNOWLEDGE.md is the public agent's baked, static knowledge surface the facts the agent should always have on hand without a tool call. It is assembled into the system prompt at invocation time.
117
+
118
+ Public agents run on the same native Claude Code PTY as the admin, dispatched via `dispatchOnce` with `role: 'public'`. The agent's `liveMemory` config toggle decides whether `memory-search` lands in the per-spawn `--allowed-tools` allowlist:
119
+
120
+ - **`liveMemory: false`** — KNOWLEDGE.md is the agent's only knowledge source. No graph access mid-conversation. Every gap in KNOWLEDGE.md is a question the agent cannot answer.
121
+ - **`liveMemory: true`** — KNOWLEDGE.md remains the baked, evergreen surface, and `memory-search` is also in the allowlist so the agent can hit the graph at message time. The memory MCP server runs with `ALLOWED_SCOPES=public` so only `scope: "public"` nodes come back; anything not marked public is invisible regardless of how the agent queries.
122
+
123
+ When `liveMemory: true`, treat KNOWLEDGE.md and `memory-search` as complementary, not alternatives. KNOWLEDGE.md owns the agent's evergreen facts (pricing, services, FAQs, contact info). `memory-search` is for the long-tail, public-scoped lookups the agent reaches for during a conversation (a named listing, a specific person, a current availability slot).
117
124
 
118
125
  All `memory-search` queries for knowledge population use admin permissions — do not pass `agentSlug`. The `agentSlug` parameter is a runtime enforcement mechanism for the public agent's MCP server, not an admin-time discovery tool. At creation time, the agent may have zero tagged nodes — searching with `agentSlug` would return nothing.
119
126
 
@@ -175,7 +182,7 @@ After creation, no template metadata persists in the agent's files. The resultin
175
182
  5. Present `IDENTITY.md` via document-editor with `filePath: "agents/{slug}/IDENTITY.md"` (draft from role description). Write on approval.
176
183
  6. Present `SOUL.md` via document-editor with `filePath: "agents/{slug}/SOUL.md"` (draft personality from user conversation). Write on approval.
177
184
  7. Present agent configuration via a single `form` component with these fields:
178
- - `liveMemory` (`checkbox`): label "Live memory", description explaining the trade-off — live memory adds ~1-2s latency per message but keeps the agent current with graph changes; leave off for static knowledge (pricing, FAQs), enable for dynamic content
185
+ - `liveMemory` (`checkbox`): label "Live memory", description explaining the trade-off — when on, the agent can call `memory-search` mid-turn against public-scoped graph content; leave off if KNOWLEDGE.md fully covers the agent's role
179
186
  - `knowledgeKeywords` (`text`): label "Knowledge keywords (max 5, comma-separated)", placeholder with examples relevant to the agent's role
180
187
  - `model` (`select`): label "Model", options with descriptions:
181
188
  - `claude-haiku-4-5` — "Haiku — Fast, cost-effective. FAQ, routing, high-volume."
@@ -265,9 +272,9 @@ The new default takes effect on the next page load. The previous default agent r
265
272
 
266
273
  Invoke the agent with `[New session. Greet the visitor.]` and stream the response to the admin chat. The admin sees exactly what a visitor would see on first load.
267
274
 
268
- ## Self-Contained Constraint
275
+ ## Runtime Surface
269
276
 
270
- Public agents run on the same native Claude Code PTY surface as the admin agent. They have no filesystem access, no MCP write tools, no subagents — every tool is on a read-only or otherwise public-safe allowlist that the manager passes to the spawn as `--allowed-tools`. Everything the agent needs to say must come from its directory files or from the public-safe tool surface. The platform assembles directory files into the system prompt at invocation time.
277
+ Public agents run on the same native Claude Code PTY as the admin, dispatched via `dispatchOnce` with `role: 'public'`. They have no filesystem access, no MCP write tools, no subagents — every tool is on a read-only, public-safe allowlist that the manager passes to the spawn as `--allowed-tools`. The agent's `liveMemory` config flag decides whether `memory-search` is in that allowlist; when present, the memory MCP server runs with `ALLOWED_SCOPES=public` so only `scope: "public"` graph nodes are reachable. Everything the agent needs to say comes from its directory files (assembled into the system prompt at invocation time) or from that public-safe tool surface.
271
278
 
272
279
  ## URL Routing
273
280
 
@@ -468,6 +468,111 @@ Removes the root-level systemd unit, `/etc/cloudflared/config.yml`, and the dupl
468
468
 
469
469
  ---
470
470
 
471
+ ## Multi-ingress: adding hostnames for prototype services
472
+
473
+ Once the brand tunnel is up (admin + public + optionally SSH/SMB), additional service hostnames can be added without re-running the OAuth/cert/create-tunnel steps. This is the pattern the venture-studio `prototype-host` skill uses to expose hosted landing pages and wedge prototypes as `https://<surface>.<BRAND_HOSTNAME>`.
474
+
475
+ The flow is: append an ingress rule to `${CFG_DIR}/config.yml` immediately before the final `http_status:404` catch-all, validate the YAML, create the DNS row, restart the brand cloudflared service.
476
+
477
+ ### 1. Append the ingress rule (awk, not sed)
478
+
479
+ `sed` cannot reliably insert before the last multi-line YAML rule. `awk` matches the literal catch-all line and inserts two lines before each match:
480
+
481
+ ```
482
+ SURFACE="<surface>" # DNS label, e.g. landing or prototype
483
+ PORT="<port>" # local port the dev server / file server binds to
484
+ HOSTNAME="${SURFACE}.${BRAND_HOSTNAME}"
485
+
486
+ # Idempotency — skip if this hostname is already in config.yml.
487
+ if grep -qE "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+${HOSTNAME}\b" "${CFG_DIR}/config.yml"; then
488
+ echo "ingress-row-exists hostname=${HOSTNAME}"
489
+ else
490
+ awk -v host="${HOSTNAME}" -v port="${PORT}" '
491
+ /^[[:space:]]*-[[:space:]]+service:[[:space:]]+http_status:404[[:space:]]*$/ {
492
+ print " - hostname: " host
493
+ print " service: http://localhost:" port
494
+ }
495
+ { print }
496
+ ' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new"
497
+ mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"
498
+ fi
499
+ ```
500
+
501
+ After the edit the file looks like:
502
+
503
+ ```yaml
504
+ tunnel: <TUNNEL_ID>
505
+ credentials-file: <CFG_DIR>/<TUNNEL_ID>.json
506
+ ingress:
507
+ - hostname: admin.<BRAND_HOSTNAME>
508
+ service: http://localhost:<PORT>
509
+ - hostname: public.<BRAND_HOSTNAME>
510
+ service: http://localhost:<PORT>
511
+ - hostname: <surface>.<BRAND_HOSTNAME>
512
+ service: http://localhost:<port>
513
+ - service: http_status:404
514
+ ```
515
+
516
+ ### 2. Validate
517
+
518
+ ```
519
+ cloudflared --origincert "${CFG_DIR}/cert.pem" \
520
+ --config "${CFG_DIR}/config.yml" \
521
+ tunnel ingress validate
522
+ ```
523
+
524
+ Non-zero exit aborts the flow; cloudflared's stderr is the literal diagnostic.
525
+
526
+ ### 3. Create the DNS row
527
+
528
+ ```
529
+ TUNNEL_ID=$(jq -r '.id // .tunnelId' "${CFG_DIR}/tunnel.state")
530
+ cloudflared --origincert "${CFG_DIR}/cert.pem" \
531
+ tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${HOSTNAME}"
532
+ ```
533
+
534
+ `--overwrite-dns` replaces any existing CNAME for the hostname (common after a prior install on the same account). Without it, `tunnel route dns` errors `record already exists`.
535
+
536
+ ### 4. Reload the connector
537
+
538
+ ```
539
+ systemctl --user restart "${BRAND}-cloudflared.service"
540
+ ```
541
+
542
+ Cloudflared does not watch `config.yml` for changes (the brand service unit launches it without `--config-watch`). Restart is the deterministic way to pick up the new rule; the brief ~1s connector blip is the same one every cloudflared upgrade incurs.
543
+
544
+ ### Verification
545
+
546
+ ```
547
+ curl -I https://<surface>.<BRAND_HOSTNAME>
548
+ ```
549
+
550
+ Anything other than `HTTP/2 200` (or the dev server's natural 200/304 response) means either the rule didn't land, the systemd unit serving `<port>` isn't running, or DNS hasn't propagated. Run `cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate` and `dig <surface>.<BRAND_HOSTNAME>` to isolate which.
551
+
552
+ ### Removing a prototype hostname
553
+
554
+ Manual for now (no scripted removal — the `prototype-host` skill does not yet ship a remove companion):
555
+
556
+ ```
557
+ # 1. Stop and disable the service unit.
558
+ systemctl --user disable --now "${BRAND}-prototype-<surface>.service"
559
+
560
+ # 2. Strip the ingress row from config.yml — match the literal hostname line plus the following service line.
561
+ awk -v host="${SURFACE}.${BRAND_HOSTNAME}" '
562
+ $0 ~ "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+" host "[[:space:]]*$" { skip = 2; next }
563
+ skip > 0 { skip--; next }
564
+ { print }
565
+ ' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new" && mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"
566
+
567
+ # 3. Delete the DNS row (dashboard, since the cloudflared CLI has no "route dns remove").
568
+ # Cloudflare → DNS → <zone> → delete CNAME for <surface>.<BRAND_HOSTNAME>.
569
+
570
+ # 4. Restart cloudflared.
571
+ systemctl --user restart "${BRAND}-cloudflared.service"
572
+ ```
573
+
574
+ ---
575
+
471
576
  ## Full reset
472
577
 
473
578
  If everything is in a bad state and you want to start over. Assumes Step 0 has been run so `${BRAND}` and `${CFG_DIR}` are set.
@@ -9,15 +9,33 @@
9
9
 
10
10
  ## macOS install
11
11
 
12
- On macOS the installer uses Homebrew + launchd instead of apt + systemd, but the operator command is the same:
12
+ On macOS the installer uses Homebrew + launchd instead of apt + systemd. No flags are required on a laptop:
13
13
 
14
14
  ```bash
15
- npx -y @rubytech/create-maxy
15
+ npx -y @rubytech/create-maxy-code # default brand
16
+ npx -y @rubytech/create-realagent-code # realagent brand
16
17
  ```
17
18
 
18
- **Prerequisite:** Homebrew. If `brew` is missing, the installer refuses with `Homebrew not found. Install from https://brew.sh and re-run.` Install Homebrew once via the official one-liner, then re-run.
19
+ **Prerequisite:** Homebrew. If `brew` is missing, the installer refuses with `Homebrew not found. Install from https://brew.sh and re-run.` Install Homebrew once via the official one-liner, then re-run. The installer never installs Homebrew itself.
20
+
21
+ **Hostname / printed URL.** Without `--hostname`, the installer reads `scutil --get LocalHostName` and prints the completion URL as `http://<that-name>.local:<port>`. No sudo, no system change — your Mac's existing local network name is what mDNS will resolve. With `--hostname <h>`, the installer sets `HostName` / `LocalHostName` / `ComputerName` to `<h>` via `sudo scutil` (one password prompt, all-or-nothing rollback within the three-call batch) so the URL becomes `http://<h>.local:<port>`. Grep `~/.<brand>/logs/install-*.log` for `[create-maxy] darwin-hostname-mode=` to confirm which path ran (`scutil-get`, `scutil-set`, or `brand-fallback`).
22
+
23
+ **LaunchAgent.** The installer registers `{{productName}}` as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.<brand-hostname>.plist` — for example `com.rubytech.maxy-code.plist`. Survives logout/login and reboot via `KeepAlive=true` and `RunAtLoad=true`. Two brands on the same Mac get two distinct plists (brand-hostname-keyed), so install order is independent. Use `launchctl print gui/$UID/com.rubytech.<brand-hostname>` for service state. The `[create-maxy] launchd-plist=<path> loaded=true` line in the install log confirms `launchctl bootstrap` accepted the plist; `loaded=false exit=<n>` is the failure signal (run `plutil -lint <path>` to diagnose).
24
+
25
+ **Cloudflare on darwin.** The installer brew-installs the `cloudflared` binary so it is on PATH, but does not invoke `cloudflared service install` or `cloudflared tunnel route dns` — public reach is opt-in. After install, the operator runs `cloudflared tunnel login` (browser-driven) followed by the existing tunnel-setup flow if they want a public address. Grep `[create-maxy] darwin-cloudflare-skip=true` in the install log to confirm the installer took the documented skip path.
26
+
27
+ **Uninstall.** `npx -y @rubytech/create-maxy-code uninstall` (or the realagent equivalent) bootsout the LaunchAgent, removes the plist, and deletes `~/.<brand>/`. Homebrew-installed dependencies (curl, git, unzip, jq, poppler, ffmpeg, node@22, neo4j, cloudflared) remain — remove them with `brew uninstall` if you want a clean slate.
28
+
29
+ **Pre-flight.** macOS < 14 is refused at pre-flight via `parseSwVers` (`sw_vers -productVersion` must be `≥ 14`).
30
+
31
+ **Diagnostic grep recipe.** After a Mac install, the canonical log path is `~/.<brand>/logs/install-*.log`. One pass tells you everything:
32
+
33
+ ```bash
34
+ grep -E '^\[create-maxy\] (platform|darwin-hostname-mode|darwin-cloudflare-skip|launchd-plist|init-logging FAILED)=' \
35
+ ~/.<brand>/logs/install-*.log
36
+ ```
19
37
 
20
- The installer registers {{productName}} as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.maxy.plist` — survives logout/login and reboot. Use `launchctl print gui/$UID/com.rubytech.maxy` for service state. `--hostname <h>` sets HostName / LocalHostName / ComputerName via `sudo scutil`. macOS < 14 is refused at pre-flight.
38
+ Every successful Mac install contains, in order: `platform=darwin`, `darwin-hostname-mode=…`, `darwin-cloudflare-skip=true`, every brew install/verify line, `launchd-plist=… loaded=true`. Absence of any of these is the failure signal.
21
39
 
22
40
  ## Initial Setup
23
41
 
@@ -197,13 +197,7 @@ When `search` is true and `query` is non-null, the rewritten query replaces the
197
197
 
198
198
  ### Knowledge retrieval gate
199
199
 
200
- The classifier is one input to a broader decision tree that determines whether `memory-search` runs:
201
-
202
- | Agent config | Classifier runs? | Search runs? |
203
- |---|---|---|
204
- | `liveMemory: true` | No | Always — the admin opted into live knowledge and the classifier cannot know what the graph contains. ~200ms cost is negligible vs. cost of a miss. |
205
- | `knowledgeBaked: true`, `liveMemory: false` | No | Never — KNOWLEDGE.md is the sole knowledge source. |
206
- | `knowledgeBaked: false` | Yes | Only if classifier says `search: true` (or classifier fails → fallback to always-search). |
200
+ On the public PTY surface the agent itself decides when to call `memory-search` — there is no server-side classifier interposed between the user message and the agent's first tool call. KNOWLEDGE.md (when present) is assembled into the agent's system prompt at spawn time. Whether `memory-search` is reachable at message time is controlled by the agent's `liveMemory` config flag: when `true`, the per-spawn allowlist includes `memory-search` and reads run with `ALLOWED_SCOPES=public`; when `false`, the agent has no graph access mid-turn.
207
201
 
208
202
  ### Observability
209
203
 
@@ -325,10 +319,7 @@ Results from keyword subscription searches are merged into the same scored map a
325
319
 
326
320
  ### Lifecycle
327
321
 
328
- | Mode | How keywords are used |
329
- |---|---|
330
- | `liveMemory: true` | Keywords are passed as `KNOWLEDGE_KEYWORDS` env var to the memory MCP server at runtime. Every query includes keyword subscription searches alongside the primary search. |
331
- | `liveMemory: false` | Keywords are used by the `update-knowledge` admin skill when regenerating KNOWLEDGE.md. The regeneration queries the graph with both direct tags and keyword subscriptions. |
322
+ Keywords are consumed by the `update-knowledge` admin skill when regenerating KNOWLEDGE.md — the regeneration query broadens the operator-tagged set with keyword matches so newly-added graph content that shares a subscribed topic lands in the next baked snapshot. There is no runtime keyword-injection path on the public PTY surface.
332
323
 
333
324
  ---
334
325
 
@@ -368,23 +359,12 @@ The final step in the retrieval pipeline is injecting retrieved content into the
368
359
 
369
360
  ### Public agent paths
370
361
 
371
- The server-side code in `claude-agent.ts` builds the system prompt before making the Anthropic API call. Three paths:
362
+ Public agents run on the same native Claude Code PTY surface as the admin, dispatched through the channel PTY-bridge with `role: 'public'`. The agent's directory files (IDENTITY.md, SOUL.md, KNOWLEDGE.md, KNOWLEDGE-SUMMARY.md when present) are assembled into the system prompt at spawn time. There is no per-turn server-side knowledge injection.
372
363
 
373
- **Path 1 Baked only** (`knowledgeBaked: true`, `liveMemory: false`):
374
- - KNOWLEDGE.md content is embedded directly in the system prompt as `<knowledge>...</knowledge>`
375
- - No runtime search. No classifier. No memory MCP server call.
376
- - KNOWLEDGE.md quality is the ceiling of the agent's factual knowledge.
364
+ Two paths, selected by the agent's `liveMemory` config flag:
377
365
 
378
- **Path 2Baked + live** (`knowledgeBaked: true`, `liveMemory: true`):
379
- - KNOWLEDGE.md is embedded as `<knowledge>...</knowledge>` (static baseline)
380
- - Runtime search also runs (always, bypassing classifier)
381
- - Results appended as `<live-memory>...</live-memory>`
382
- - Agent sees both: curated knowledge file + fresh graph results
383
-
384
- **Path 3 — Memory-search only** (`knowledgeBaked: false`):
385
- - No KNOWLEDGE.md exists (e.g., on a fresh install before the operator has had any conversations)
386
- - Classifier decides whether to search (or falls back to always-search)
387
- - Results injected as `<memory>...</memory>`
366
+ - **`liveMemory: false`** `memory-search` is excluded from the per-spawn `--allowed-tools` allowlist. The agent has no graph access mid-conversation; KNOWLEDGE.md is the ceiling of factual knowledge.
367
+ - **`liveMemory: true`** — `memory-search` is in the allowlist. The agent decides at message time whether to call it; reads run against the graph with `ALLOWED_SCOPES=public` so only public-scoped nodes return. KNOWLEDGE.md and the live `memory-search` surface are complementary — the baked file covers evergreen facts; the live tool covers the long-tail public-scoped lookups.
388
368
 
389
369
  ### KNOWLEDGE.md staleness guard
390
370
 
@@ -496,6 +476,10 @@ A second boot gate walks every specialist `.md` under `platform/templates/specia
496
476
 
497
477
  This gate was Task 173. The `brand-excluded` branch closes the recurring crash-restart loop on brands that ship without a plugin the shared `personal-assistant.md` template references (e.g. `realagent-code` excludes `telegram` while the template hard-codes `mcp__telegram__*`). The brand-agnostic template stays a single file; the brand-aware filter expresses what the specialist *may* do on this install while the template expresses what it *can* do across brands. Tool typos and renamed plugins still refuse to boot — only namespaces explicitly named in `plugins.excluded` are demoted to strip-and-warn.
498
478
 
479
+ **Brand-foreign premium bundles (Task 343).** The same drift gate walks `premium-plugins/<bundle>/agents/` for every bundle present on disk. On brands whose installer ships every premium bundle but only intends to surface a subset, a foreign-bundle specialist file (e.g. `real-agent/listing-curator.md` on a `maxy-code` install) used to reach the drift assertion and refuse boot the moment any of its declared tools missed the active brand's tool surface — the `image-fetch` tool that registered at runtime but was undeclared in `memory/PLUGIN.md` produced exactly this loop. The manager resolves `brand.json#shipsPremiumBundles` into the same three modes `paths.ts` already enforces for the UI: `true` ships every premium bundle, a `string[]` ships only the named bundle directories, `false`/missing ships none (fail-closed). Premium bundles outside the resolved mode are dropped before drift, with one `agents-dir-skipped path=<p> reason=brand-foreign-bundle bundle=<n>` line per drop. When the premium root exists and the mode resolves to `none` (so every premium dir filters), one additional `brand-foreign-skip-all path=<premiumPluginsRoot> reason=shipsPremiumBundles-missing-or-empty premium-dirs-skipped=<N>` line surfaces the misconfig. `platform/plugins/` candidates are never filtered — brand-foreign is a premium-tree concept only.
480
+
481
+ **Structured journald mirror for boot-failed (Task 343).** Every `boot-failed reason=specialist-tool-drift …` line is mirrored to journald via `systemd-cat -t maxy-csm -p err` with the fields `specialist=`, `tool=`, `drift_reason=`, `agent_path=` so `journalctl --user -u <brand>-claude-session-manager.service -t maxy-csm` can filter by any of them without grep on `server.log`. The stdout line stays unchanged so the existing diagnostic one-liners keep working. `systemd-cat` absence (e.g. macOS dev box) is swallowed — the stdout line is the primary surface; the structured emit is auxiliary.
482
+
499
483
  **Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.
500
484
 
501
485
  **Brand-process start counter (Task 173).** `platform/ui/server-init.cjs` increments a persistent counter at `/tmp/server-init-<accountId>-restart.count` on every fresh start and emits `[server-init] start count=<N> account=<accountId> counter-path=<…>` to `server.log`. /tmp clears on reboot, so a clean reboot starts the count fresh; any value `>1` between operator-observed reboots means the brand process (driven by its `Requires=<brand>-claude-session-manager.service` clause) is restarting. The diagnostic one-liner is `grep '\[server-init\] start' ~/.<brand>/logs/server.log | tail -5` — the trailing `count=` value is the loop depth without counting SIGTERMs.