@rubytech/create-realagent 1.0.818 → 1.0.820

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 (58) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.d.ts +2 -0
  3. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.js +168 -0
  5. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.js.map +1 -0
  6. package/payload/platform/lib/graph-write/dist/index.d.ts +28 -5
  7. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  8. package/payload/platform/lib/graph-write/dist/index.js +97 -3
  9. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  10. package/payload/platform/lib/graph-write/src/__tests__/action-provenance-gate.test.ts +191 -0
  11. package/payload/platform/lib/graph-write/src/index.ts +90 -9
  12. package/payload/platform/neo4j/schema.cypher +24 -0
  13. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +25 -8
  14. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  15. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +70 -20
  16. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  17. package/payload/platform/plugins/docs/references/graph.md +20 -0
  18. package/payload/platform/plugins/docs/references/internals.md +16 -0
  19. package/payload/platform/plugins/email/PLUGIN.md +2 -0
  20. package/payload/platform/plugins/memory/PLUGIN.md +6 -0
  21. package/payload/platform/plugins/memory/mcp/dist/index.js +15 -4
  22. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts +8 -0
  24. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  25. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +26 -2
  26. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  27. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts +20 -0
  28. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
  29. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +40 -1
  30. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  31. package/payload/platform/plugins/memory/references/schema-base.md +8 -0
  32. package/payload/platform/plugins/tasks/PLUGIN.md +2 -2
  33. package/payload/platform/plugins/tasks/mcp/dist/index.js +10 -5
  34. package/payload/platform/plugins/tasks/mcp/dist/index.js.map +1 -1
  35. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts +27 -1
  36. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts.map +1 -1
  37. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js +45 -2
  38. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js.map +1 -1
  39. package/payload/platform/plugins/tasks/mcp/dist/tools/task-update.d.ts +20 -1
  40. package/payload/platform/plugins/tasks/mcp/dist/tools/task-update.d.ts.map +1 -1
  41. package/payload/platform/plugins/tasks/mcp/dist/tools/task-update.js +46 -6
  42. package/payload/platform/plugins/tasks/mcp/dist/tools/task-update.js.map +1 -1
  43. package/payload/server/chunk-5OG7TUQL.js +315 -0
  44. package/payload/server/chunk-CZGOB575.js +593 -0
  45. package/payload/server/chunk-NUXYHO6N.js +10079 -0
  46. package/payload/server/chunk-SALVIGXH.js +1116 -0
  47. package/payload/server/chunk-ZT6LKDTP.js +2238 -0
  48. package/payload/server/client-pool-IQU6H43X.js +32 -0
  49. package/payload/server/cloudflare-task-tracker-Q4X5BYR7.js +17 -0
  50. package/payload/server/maxy-edge.js +4 -3
  51. package/payload/server/neo4j-migrations-CYIKMSEO.js +366 -0
  52. package/payload/server/public/assets/admin-BoGPEBe_.js +352 -0
  53. package/payload/server/public/assets/{graph-DeH6ulGh.js → graph-LLMJa4Ch.js} +1 -1
  54. package/payload/server/public/assets/{page-WIAWD2Oi.js → page-DoaF3DB0.js} +1 -1
  55. package/payload/server/public/graph.html +2 -2
  56. package/payload/server/public/index.html +2 -2
  57. package/payload/server/server.js +276 -16
  58. package/payload/server/public/assets/admin-CdVYoqKD.js +0 -352
@@ -13,14 +13,24 @@ export * from "./audit.js";
13
13
  * maps, and flat props are queryable — `MATCH (n) WHERE n.createdBySession
14
14
  * = $id RETURN n` is the forensic entry point).
15
15
  *
16
+ * Process provenance doctrine (Task 885): writes targeting any label in
17
+ * `ACTION_PROVENANCE_LABELS` (Person, UserProfile, AdminUser, Organization,
18
+ * LocalBusiness, CloudflareTunnel, CloudflareHostname) must include exactly
19
+ * one inbound `:PRODUCED` edge whose source is a `:Task` node. This makes
20
+ * every durable LLM-tool-driven entity creation traversable from the Task
21
+ * node that produced it, and forward from Task→Conversation via
22
+ * `:RAISED_DURING`. Bootstrap writes (`createdBy.agent === 'system'`) are
23
+ * exempt — installer / migration / lazy-create paths run as system writers.
24
+ *
16
25
  * Rejection paths (every one emits a stderr log the admin server pipes to
17
26
  * server.log, so orphan pressure is visible per-write not just in the
18
27
  * hourly [graph-health] signal):
19
- * - zero relationships → `[graph-write] reject reason=zero-relationships`
20
- * - unresolved target id → `[graph-write] reject reason=unresolved-target`
21
- * - removed-feature write → `[graph-write] reject reason=removed-feature`
22
- * (Task 884 `:ReviewAlert` and `:Event {actionTool:"review-digest-compose"}`
23
- * are deleted features; the gate catches doctrine-compliant writers
28
+ * - zero relationships → `[graph-write] reject reason=zero-relationships`
29
+ * - unresolved target id → `[graph-write] reject reason=unresolved-target`
30
+ * - missing action provenance → `[graph-write] reject reason=missing-action-provenance`
31
+ * - removed-feature write → `[graph-write] reject reason=removed-feature`
32
+ * (`:ReviewAlert` and `:Event {actionTool:"review-digest-compose"}` are
33
+ * deleted features; the gate catches doctrine-compliant writers
24
34
  * re-introducing them. The vitest tombstone grep is the primary fence
25
35
  * for raw `session.run("MERGE …")` callers that bypass this primitive.)
26
36
  *
@@ -31,6 +41,43 @@ export * from "./audit.js";
31
41
  * this field for access control — it is forensic, not a security boundary.
32
42
  */
33
43
 
44
+ /**
45
+ * Labels that require an inbound `:PRODUCED` edge from a `:Task` (Task 885).
46
+ * Bootstrap writes with `createdBy.agent === 'system'` are exempt — that
47
+ * carve-out covers PIN-setup `writeAdminUserAndPerson`, schema migrations,
48
+ * and the lazy `loadUserProfile` MERGE path that bypasses this primitive
49
+ * entirely (raw Cypher in `platform/ui/app/lib/neo4j-store.ts`).
50
+ *
51
+ * The set is intentionally not exhaustive — labels added here become a
52
+ * blocking gate for every LLM-tool write of that label. Add a label only
53
+ * after every legitimate writer of it can either (a) carry a Task-PRODUCED
54
+ * edge, or (b) declare itself as `agent: 'system'`.
55
+ */
56
+ export const ACTION_PROVENANCE_LABELS: ReadonlySet<string> = new Set([
57
+ "Person",
58
+ "UserProfile",
59
+ "AdminUser",
60
+ "Organization",
61
+ "LocalBusiness",
62
+ "CloudflareTunnel",
63
+ "CloudflareHostname",
64
+ ]);
65
+
66
+ function requiresActionProvenance(labels: readonly string[]): boolean {
67
+ for (const label of labels) {
68
+ if (ACTION_PROVENANCE_LABELS.has(label)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ function findProducedFromTaskCandidates(
74
+ relationships: readonly GraphRelationship[],
75
+ ): GraphRelationship[] {
76
+ return relationships.filter(
77
+ (r) => r.type === "PRODUCED" && r.direction === "incoming",
78
+ );
79
+ }
80
+
34
81
  import type { Session } from "neo4j-driver";
35
82
 
36
83
  export interface GraphRelationship {
@@ -128,10 +175,16 @@ export async function writeNodeWithEdges(
128
175
  return await session.executeWrite(async (tx) => {
129
176
  const targetIds = relationships.map((r) => r.targetNodeId);
130
177
  const check = await tx.run(
131
- `UNWIND $ids AS id MATCH (t) WHERE elementId(t) = id RETURN count(DISTINCT t) AS found`,
132
- { ids: targetIds }
178
+ `UNWIND $ids AS id MATCH (t) WHERE elementId(t) = id RETURN elementId(t) AS id, labels(t) AS labels`,
179
+ { ids: targetIds },
133
180
  );
134
- const found = check.records[0].get("found").toNumber();
181
+
182
+ const labelsByTarget = new Map<string, string[]>();
183
+ for (const rec of check.records) {
184
+ labelsByTarget.set(rec.get("id") as string, rec.get("labels") as string[]);
185
+ }
186
+
187
+ const found = labelsByTarget.size;
135
188
  const uniqueRequested = new Set(targetIds).size;
136
189
  if (found !== uniqueRequested) {
137
190
  process.stderr.write(
@@ -142,6 +195,34 @@ export async function writeNodeWithEdges(
142
195
  );
143
196
  }
144
197
 
198
+ // Process provenance gate (Task 885). Labels in
199
+ // ACTION_PROVENANCE_LABELS require a `:Task` source on at least one
200
+ // inbound :PRODUCED edge so every durable LLM-tool entity write is
201
+ // traversable from the Task that produced it. Bootstrap writes
202
+ // (createdBy.agent === 'system') are exempt — installer + migration
203
+ // paths predate any Task. The check runs INSIDE the transaction so
204
+ // the labels-by-target map is consistent with target resolution.
205
+ let producedByTaskId: string | null = null;
206
+ if (
207
+ requiresActionProvenance(labels) &&
208
+ (createdBy.agent ?? "") !== "system"
209
+ ) {
210
+ const candidates = findProducedFromTaskCandidates(relationships);
211
+ const taskCandidates = candidates.filter((r) => {
212
+ const lbls = labelsByTarget.get(r.targetNodeId);
213
+ return Array.isArray(lbls) && lbls.includes("Task");
214
+ });
215
+ if (taskCandidates.length === 0) {
216
+ process.stderr.write(
217
+ `[graph-write] reject reason=missing-action-provenance labels=${labelCsv} agent=${agentLabel}\n`
218
+ );
219
+ throw new Error(
220
+ `Process provenance doctrine violated: write to ${labelCsv} requires an inbound :PRODUCED edge from a :Task (createdBy.agent='${agentLabel}'). See .docs/neo4j.md (Process provenance doctrine).`
221
+ );
222
+ }
223
+ producedByTaskId = taskCandidates[0].targetNodeId;
224
+ }
225
+
145
226
  let nodeRes;
146
227
  try {
147
228
  nodeRes = await tx.run(
@@ -212,7 +293,7 @@ export async function writeNodeWithEdges(
212
293
  }
213
294
 
214
295
  process.stderr.write(
215
- `[graph-write] accepted labels=${labelCsv} edges=${edgesCreated} createdByAgent=${createdBy.agent ?? "unknown"} createdByTool=${createdBy.tool ?? createdBy.source ?? "unknown"}\n`
296
+ `[graph-write] accepted labels=${labelCsv} edges=${edgesCreated} createdByAgent=${createdBy.agent ?? "unknown"} createdByTool=${createdBy.tool ?? createdBy.source ?? "unknown"} producedByTask=${producedByTaskId ?? "none"}\n`
216
297
  );
217
298
 
218
299
  return { nodeId, labels: nodeLabels, edgesCreated };
@@ -1018,3 +1018,27 @@ FOR (n:Section) ON (n.createdBySession);
1018
1018
 
1019
1019
  CREATE INDEX task_created_by_session IF NOT EXISTS
1020
1020
  FOR (n:Task) ON (n.createdBySession);
1021
+
1022
+ // ----------------------------------------------------------
1023
+ // CloudflareTunnel / CloudflareHostname (Task 885) — graph audit of
1024
+ // the tunnel-login deterministic flow. Written by the
1025
+ // /api/admin/cloudflare/setup endpoint after setup-tunnel.sh exits 0,
1026
+ // linked via (Task {kind:'cloudflare-tunnel-login'})-[:PRODUCED]->.
1027
+ // CloudflareHostname carries the routed FQDN; CloudflareTunnel
1028
+ // carries the connector identity. Source of truth remains the on-disk
1029
+ // cert.pem + tunnel.state + config.yml — these nodes are the graph
1030
+ // projection so operators can MATCH from a Conversation to "which
1031
+ // cloudflare resources did this onboarding produce?"
1032
+ // ----------------------------------------------------------
1033
+
1034
+ CREATE CONSTRAINT cloudflare_tunnel_id_unique IF NOT EXISTS
1035
+ FOR (t:CloudflareTunnel) REQUIRE t.tunnelId IS UNIQUE;
1036
+
1037
+ CREATE INDEX cloudflare_tunnel_account IF NOT EXISTS
1038
+ FOR (t:CloudflareTunnel) ON (t.accountId);
1039
+
1040
+ CREATE CONSTRAINT cloudflare_hostname_unique IF NOT EXISTS
1041
+ FOR (h:CloudflareHostname) REQUIRE (h.accountId, h.hostnameValue) IS UNIQUE;
1042
+
1043
+ CREATE INDEX cloudflare_hostname_account IF NOT EXISTS
1044
+ FOR (h:CloudflareHostname) ON (h.accountId);
@@ -201,21 +201,38 @@ Pin the operator's persona and bootstrap the graph nodes that satisfy the graph-
201
201
 
202
202
  **Wait for the user's submission.** If the user picks "Other" or types free text instead of selecting, ask them which of the two personas best describes them and re-render the select. Do not proceed without one of the two documented modes — the agent must not improvise a third path. If the user pivots off-topic mid-flow, answer their question briefly and re-render the select; step 9 stays incomplete until they pick a mode.
203
203
 
204
- **Call `onboarding-step9-mode` with the chosen mode before any graph write or skill invocation.** The tool emits the diagnostic log line and returns the deterministic next-action prose. Branch on the mode:
204
+ **Call `onboarding-step9-mode` with the chosen mode before any graph write or skill invocation.** The tool emits the diagnostic log line and returns the deterministic next-action prose.
205
+
206
+ **Open the action record with `task-create` (Task 885 process-provenance doctrine).** Before any graph write or skill invocation, call:
207
+
208
+ ```
209
+ task-create
210
+ name: "Establish operator owner — onboarding step 9"
211
+ description: "<one-line summary of the chosen mode and what entities will be produced>"
212
+ status: "running"
213
+ kind: "onboarding-establish-owner"
214
+ inputsProvided: ["mode"]
215
+ ```
216
+
217
+ The returned `taskId` is the action-provenance handle for this step — every subsequent `memory-write` for an action-provenance-gated label (`Person`, `UserProfile`, `AdminUser`, `Organization`, `LocalBusiness`) MUST pass it as `producedByTaskId` so the inbound `:PRODUCED` edge from the Task is composed into the write. The Task is auto-linked to the current `AdminConversation` via `RAISED_DURING` (this is what makes `MATCH (c:AdminConversation)<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(entity)` traversable from the conversation that initiated onboarding).
218
+
219
+ Then branch on the mode.
205
220
 
206
221
  ### `business-owner`
207
222
 
208
- Invoke the `business-profile` skill. Follow its first-run path: create the `AdminUser` node, create the `LocalBusiness` node, collect identity + address + whichever additional domains (hours, services, FAQs, brand assets) the user provides. When `business-profile` reports that both nodes exist in the graph, call `onboarding-complete-step` with step 9. Do not mark step 9 complete before both nodes exist — the gate's precondition must be real, not just recorded.
223
+ Invoke the `business-profile` skill, passing the `taskId` so the skill can thread it as `producedByTaskId` into every `memory-write` it issues. The skill follows its first-run path: create the `AdminUser` node, create the `LocalBusiness` node, create the `Organization` node, collect identity + address + whichever additional domains (hours, services, FAQs, brand assets) the user provides. When `business-profile` reports that the required nodes exist in the graph, call `task-update(appendStep:"business-profile-complete")` then write the `HAS_PROFILE` edge from the personal-profile `Person` to the operator's `UserProfile` via `memory-update` (one `memory-search` to resolve both elementIds; the `UserProfile` already exists from step 6 onwards via the lazy-create in `loadUserProfile`). Then call `task-complete(taskId)` and `onboarding-complete-step` with step 9. Do not mark step 9 complete before the required nodes + the HAS_PROFILE edge exist — the gate's precondition must be real, not just recorded.
209
224
 
210
225
  ### `personal`
211
226
 
212
- Personal mode does not register a `LocalBusiness`. The `AdminUser` and personal-profile `Person` nodes were written deterministically at PIN setup time (Task 830 — `writeAdminUserAndPerson`), so this step only enriches the existing Person with an email:
227
+ Personal mode does not register a `LocalBusiness`. The `AdminUser` and personal-profile `Person` nodes were written deterministically at PIN setup time (Task 830 — `writeAdminUserAndPerson`, run as `createdBy.agent === 'system'` and therefore exempt from the action-provenance gate), so this step only enriches the existing Person with operator-identity fields and links it to the `UserProfile`:
213
228
 
214
- 1. **Ask the user for their email** in one short conversational message — {{productName}} needs an email or phone number on the personal-profile node for downstream features (notifications, contact-method matching).
215
- 2. **Locate the personal-profile `Person`.** Call `memory-search` with the user's name from `admin-identity` plus `role: "admin-personal"` to find the existing node bound to the `AdminUser` via `OWNS`.
216
- 3. **Attach the email.** Call `memory-update` on that Person with `properties: { email }`. Do NOT create a new Person — the set-pin path already created and bound it.
217
- 4. **Mark step 9 complete.** Call `onboarding-complete-step` with step 9.
229
+ 1. **Ask the user for their email AND phone number** in one short conversational message — {{productName}} stores both on the personal-profile Person for downstream features (notifications, contact-method matching, identity-coverage signal that the agent uses to detect missing identity in future turns). The user may decline either; record what they provide. (Operator-identity fix: the system-prompt's "Identity coverage" block surfaces missing identity fields on every turn, so a partial answer becomes a follow-up prompt rather than a silent gap.)
230
+ 2. **Attach the identity to Person.** Call `profile-update` with `personFields: { email: "<value>", telephone: "<value>" }` (omit either key the user declined). The tool resolves the personal-profile Person via `(au:AdminUser {userId:$you})-[:OWNS]->(p:Person)` server-side and writes the canonical `email`/`telephone` fields. Use canonical `telephone` — `phone` is the schema synonym, not the canonical name; `profile-update` rejects `phone` rather than silently rewriting it. The tool throws if the OWNS edge is missing rather than silently no-oping (the PIN-setup path is the only place that edge is created — a missing edge means PIN setup never ran or was rolled back).
231
+ 3. **Append the step.** Call `task-update(appendStep:"identity-attached")`.
232
+ 4. **Link the personal-profile `Person` to the `UserProfile`.** Call `memory-search` to resolve the `UserProfile` elementId for the operator (the lazy `loadUserProfile` write created it on the first admin session). Then call `memory-update` on the Person to add the `HAS_PROFILE` edge to the UserProfile. (`HAS_PROFILE` from `:Person` is a sibling pattern to the existing `AdminUser→HAS_PROFILE→UserProfile`; both are valid sources for the same edge type. See [schema-base.md Relationship Patterns](../../../memory/references/schema-base.md).)
233
+ 5. **Close the action record.** Call `task-update(appendStep:"profile-linked")` then `task-complete(taskId)`.
234
+ 6. **Mark step 9 complete.** Call `onboarding-complete-step` with step 9.
218
235
 
219
236
  After step 9 completes in personal mode, tell the user that {{productName}} is configured for personal use — their employer (if any) is not registered here. If they later become the operator for a business of their own, they can ask {{productName}} to set up a business profile, which invokes the `business-profile` skill directly.
220
237
 
221
- If the user declines to bootstrap during step 9 in any mode, leave step 9 incomplete. The next session will resume here, and any attempt to write user-domain data will surface `Write blocked (no-admin-user)` or `Write blocked (no-local-business)` via the gate, pulling the agent back into this step.
238
+ If the user declines to bootstrap during step 9 in any mode, leave step 9 incomplete AND call `task-update(taskId, status:"failed", errorMessage:"<one-line reason>")` so the action record reflects the abandonment instead of dangling in `running` forever. The next session will resume here with a fresh `task-create` (the prior failed Task stays in the graph as the audit record). Any attempt to write user-domain data will surface `Write blocked (no-admin-user)` or `Write blocked (no-local-business)` via the gate, pulling the agent back into this step.
@@ -30,7 +30,7 @@ The plugin registers no agent-facing MCP tools. Every Cloudflare operation is dr
30
30
 
31
31
  | Script | Purpose |
32
32
  |---|---|
33
- | [`scripts/setup-tunnel.sh`](scripts/setup-tunnel.sh) | Autonomous end-to-end setup: OAuth login, tunnel create, DNS route, config + state, service restart, post-restart verification. Invocation: `~/setup-tunnel.sh <brand> <port> <admin-hostname> [<public-hostname>] [<apex-hostname>]`. Apex hostnames print an `ACTION REQUIRED` block for the dashboard record the CLI cannot create. Step 1 (earlier platform fixes — wrappers faithfully relay third-party CLI) spawns `cloudflared tunnel login`, extracts the argotunnel URL from its stdout, mechanically opens it on the Pi VNC chromium (`DISPLAY=${DISPLAY:-:99} /usr/bin/chromium <url> &`), then polls for `~/.cloudflared/cert.pem` while the operator clicks the zone row + Authorize on the VNC. 180 s budget with a 2-second `step=oauth-login result=awaiting-cert` heartbeat. No CDP auto-click, no DOM matcher. |
33
+ | [`scripts/setup-tunnel.sh`](scripts/setup-tunnel.sh) | Autonomous end-to-end setup: OAuth login, tunnel resolve (operator-supplied identity), DNS route, config + state, service restart, post-restart verification. Invocation: `~/setup-tunnel.sh <brand> <port> <admin-hostname> [<public-hostname>] [<apex-hostname>]`. Required env: `STREAM_LOG_PATH`, `ACCOUNT_DIR`, AND exactly one of `TUNNEL_ID` (operator selected an existing tunnel from `/api/admin/cloudflare/tunnels`) or `TUNNEL_NAME` (operator typed a name to create) per the operator-selected-tunnel fix. The pre-fix derivation `${BRAND}-$(hostname -s)` is removed — the operator's logged-in Cloudflare account is the source of truth for which tunnel exists. Apex hostnames print an `ACTION REQUIRED` block for the dashboard record the CLI cannot create. Step 1 (wrappers faithfully relay third-party CLI) spawns `cloudflared tunnel login`, extracts the argotunnel URL from its stdout, mechanically opens it on the Pi VNC chromium (`DISPLAY=${DISPLAY:-:99} /usr/bin/chromium <url> &`), then polls for `~/.cloudflared/cert.pem` while the operator clicks the zone row + Authorize on the VNC. 180 s budget with a 2-second `step=oauth-login result=awaiting-cert` heartbeat. No CDP auto-click, no DOM matcher. |
34
34
  | [`scripts/reset-tunnel.sh`](scripts/reset-tunnel.sh) | Deletes every tunnel on the brand's CF account and wipes `${CFG_DIR}`. Does not touch the platform service, stray CNAMEs, or token-mode connectors — those require dashboard cleanup or `pkill`. Invocation: `~/reset-tunnel.sh <brand>`. No polling blocks — every long-wait is bounded by `cloudflared`'s network round-trip, so no heartbeat contract applies. |
35
35
 
36
36
  ### Skills
@@ -274,32 +274,82 @@ if [ ! -f "${CFG_DIR}/cert.pem" ]; then
274
274
  fi
275
275
 
276
276
  # --------------------------------------------------------------------------
277
- # Step 2+3: Create tunnel if absent; otherwise reuse. Capture UUID.
278
- # Emit phase_line step=tunnel-resolve with action=reused|created so the
279
- # stream log tailer shows which tunnel identity Steps 4+5 are writing
280
- # against (Task 559 Bug B: previously a bare `echo` that only surfaced
281
- # in the Bash tool_result after subprocess exit).
277
+ # Step 2+3: Resolve the tunnel identity from operator input (Task 886 §B).
278
+ #
279
+ # Pre-Task-886 the script derived TUNNEL_NAME locally as "${BRAND}-$(hostname
280
+ # -s)" and reused-or-created. That breaks the same doctrine as pre-Task-589
281
+ # zone selection: the local hostname has no authority over which tunnel the
282
+ # operator's logged-in Cloudflare account holds. A renamed device produced a
283
+ # new tunnel; existing CNAMEs continued to point at the old one; the chat
284
+ # said "Done. tunnel=maxy-maxytest" while the operator's `maxytest` hostname
285
+ # kept resolving via a stale orphan.
286
+ #
287
+ # New contract: the form (rendered via /api/admin/cloudflare/tunnels list) is
288
+ # the source of truth. The endpoint passes exactly one of:
289
+ # TUNNEL_ID — operator selected an existing tunnel from the list
290
+ # TUNNEL_NAME — operator typed a name to create a new tunnel
291
+ # The endpoint validates the exactly-one constraint; the script enforces it
292
+ # again here as defence in depth. Setting both, or neither, is a misuse.
282
293
  # --------------------------------------------------------------------------
283
294
 
284
- TUNNEL_NAME="${BRAND}-$(hostname -s)"
285
- TUNNEL_ID="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json 2>/dev/null \
286
- | jq -r --arg N "${TUNNEL_NAME}" '.[]? | select(.name == $N) | .id' | head -1)"
287
- TUNNEL_ACTION="reused"
288
- if [ -z "${TUNNEL_ID}" ] || [ "${TUNNEL_ID}" = "null" ]; then
295
+ if [ -n "${TUNNEL_ID:-}" ] && [ -n "${TUNNEL_NAME:-}" ]; then
296
+ phase_line setup-tunnel step=tunnel-resolve result=error reason=both-set
297
+ echo "ERROR: TUNNEL_ID and TUNNEL_NAME are mutually exclusive pass exactly one." >&2
298
+ exit 1
299
+ fi
300
+ if [ -z "${TUNNEL_ID:-}" ] && [ -z "${TUNNEL_NAME:-}" ]; then
301
+ phase_line setup-tunnel step=tunnel-resolve result=error reason=neither-set
302
+ echo "ERROR: TUNNEL_ID (selected) or TUNNEL_NAME (explicit-create) is required." >&2
303
+ echo " The form derives one of these from operator input via" >&2
304
+ echo " /api/admin/cloudflare/tunnels — re-run the form, do not" >&2
305
+ echo " invoke this script directly without one of the env vars." >&2
306
+ exit 1
307
+ fi
308
+
309
+ if [ -n "${TUNNEL_ID:-}" ]; then
310
+ # Operator-selected branch. Resolve the name back from `tunnel list` for
311
+ # the log line + tunnel.state file. A missing row here means the operator
312
+ # selected a tunnel that has since been deleted from another surface
313
+ # (rare but possible) — fail loudly so the operator sees the cause
314
+ # rather than getting a silent fallback to "create new".
315
+ TUNNEL_NAME="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json 2>/dev/null \
316
+ | jq -r --arg I "${TUNNEL_ID}" '.[]? | select(.id == $I) | .name' | head -1)"
317
+ if [ -z "${TUNNEL_NAME}" ] || [ "${TUNNEL_NAME}" = "null" ]; then
318
+ phase_line setup-tunnel step=tunnel-resolve result=error \
319
+ reason=selected-tunnel-not-found tunnel_id="${TUNNEL_ID}"
320
+ echo "ERROR: TUNNEL_ID ${TUNNEL_ID} is not on the logged-in Cloudflare account." >&2
321
+ echo " It may have been deleted from the dashboard since the form loaded." >&2
322
+ echo " Re-render the form to refresh the tunnel list." >&2
323
+ exit 1
324
+ fi
325
+ TUNNEL_SOURCE="operator-selected"
326
+ else
327
+ # Operator-create branch. Refuse to silently reuse an existing tunnel of
328
+ # the same name — that recreates the pre-Task-886 silent-collision bug.
329
+ # The operator should have picked it from the list.
330
+ EXISTING_ID="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json 2>/dev/null \
331
+ | jq -r --arg N "${TUNNEL_NAME}" '.[]? | select(.name == $N) | .id' | head -1)"
332
+ if [ -n "${EXISTING_ID}" ] && [ "${EXISTING_ID}" != "null" ]; then
333
+ phase_line setup-tunnel step=tunnel-resolve result=error \
334
+ reason=name-already-exists tunnel_name="${TUNNEL_NAME}" tunnel_id="${EXISTING_ID}"
335
+ echo "ERROR: a tunnel named ${TUNNEL_NAME} already exists (id=${EXISTING_ID})." >&2
336
+ echo " Re-render the form and select it from the list, or pick a different name." >&2
337
+ exit 1
338
+ fi
289
339
  cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel create "${TUNNEL_NAME}"
290
340
  TUNNEL_ID="$(cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list --output json \
291
341
  | jq -r --arg N "${TUNNEL_NAME}" '.[]? | select(.name == $N) | .id' | head -1)"
292
- TUNNEL_ACTION="created"
293
- fi
294
- if [ -z "${TUNNEL_ID}" ] || [ "${TUNNEL_ID}" = "null" ]; then
295
- phase_line setup-tunnel step=tunnel-resolve result=error \
296
- reason=uuid-missing tunnel_name="${TUNNEL_NAME}"
297
- echo "ERROR: failed to create or find tunnel ${TUNNEL_NAME}" >&2
298
- exit 1
342
+ if [ -z "${TUNNEL_ID}" ] || [ "${TUNNEL_ID}" = "null" ]; then
343
+ phase_line setup-tunnel step=tunnel-resolve result=error \
344
+ reason=create-then-uuid-missing tunnel_name="${TUNNEL_NAME}"
345
+ echo "ERROR: created tunnel ${TUNNEL_NAME} but its UUID is missing from tunnel list." >&2
346
+ exit 1
347
+ fi
348
+ TUNNEL_SOURCE="operator-created"
299
349
  fi
300
- phase_line setup-tunnel step=tunnel-resolve tunnel_name="${TUNNEL_NAME}" \
301
- tunnel_id="${TUNNEL_ID}" action="${TUNNEL_ACTION}"
302
- echo "tunnel: ${TUNNEL_NAME} (${TUNNEL_ID})"
350
+ phase_line setup-tunnel step=tunnel-resolve source="${TUNNEL_SOURCE}" \
351
+ tunnel_id="${TUNNEL_ID}" tunnel_name="${TUNNEL_NAME}"
352
+ echo "tunnel: ${TUNNEL_NAME} (${TUNNEL_ID}) [${TUNNEL_SOURCE}]"
303
353
 
304
354
  # --------------------------------------------------------------------------
305
355
  # Step 3b: Zone pre-flight. Before routing DNS, verify every non-apex
@@ -25,7 +25,7 @@ Ask the agent to set up Cloudflare. The agent first confirms the domain is alrea
25
25
  When you submit, the `/api/admin/cloudflare/setup` endpoint runs — in strict order — `setRemotePassword`, launches a `cloudflare-setup` action (earlier platform fixes: `systemd-run --user` transient unit wrapping `setup-tunnel.sh <brand> <port> <hostname...>`), and registers a post-exit handler to write alias-domains for every non-`public.*` public or apex hostname (so e.g. `chat.yourdomain.com` is classified as public by `isPublicHost`). The script runs end-to-end:
26
26
 
27
27
  - `cloudflared tunnel login` — OAuth browser sign-in. The VNC browser opens the Cloudflare authorize page; pick the account that owns your domain, click Authorize. `cert.pem` lands.
28
- - Tunnel creation under the naming convention `{brand}-{hostname}` (e.g. `maxy-neo`). Stream log emits `step=tunnel-resolve action=reused|created` once the UUID is known so the admin agent can see which tunnel the later steps will write against.
28
+ - Tunnel resolution from operator-supplied identity (operator-selected-tunnel fix). The form populates a tunnel-select dropdown from `GET /api/admin/cloudflare/tunnels` (which calls `cloudflared tunnel list --output json` on your logged-in account). You either pick an existing tunnel from the list or type a name to create a new one. The form posts EXACTLY ONE of `{tunnelId, tunnelName}`; the script enforces the same constraint as defence in depth. Pre-fix the script derived `${BRAND}-$(hostname -s)` locally; that broke the operator-state-is-authoritative doctrine and silently created orphan tunnels whenever the device hostname changed. Stream log emits `step=tunnel-resolve source=operator-selected|operator-created tunnel_id=… tunnel_name=…` once the UUID is known.
29
29
  - **Zone pre-flight** — for every non-apex hostname the script queries `1.1.1.1` for the registrable parent's NS records and refuses the whole run if they don't point at Cloudflare. Stream log: `step=zone-preflight result=ok|error zones_on_account=… missing_parent_for=…`. Catches "domain not on Cloudflare"; does not catch "domain on a different Cloudflare account than `cert.pem` is bound to" — that case surfaces later via `tunnel-status`.
30
30
  - `cloudflared tunnel route dns` for each subdomain hostname. Apex hostnames cannot be routed this way — the script prints an **ACTION REQUIRED** block naming the exact dashboard record to add or edit. Stream log emits `step=route-dns hostname=… tunnel_id=…` before the call and `step=route-dns hostname=… result=ok|apex-skip|error` after; on error the bounded cloudflared stderr (≤400 chars) rides in the same phase line. **The script does not parse cloudflared's stdout** — exit code is the sole decision signal, so all three legitimate cloudflared output shapes (new record, overwrite, idempotent "already configured") are treated as success.
31
31
  - `config.yml` and `tunnel.state` written under `${CFG_DIR}`.
@@ -31,6 +31,26 @@ readable when you zoom out to see a large subgraph.
31
31
  Tier transitions are debounced so spinning the scroll wheel does not cause
32
32
  label flicker; labels only rewrite once zoom settles on a new tier.
33
33
 
34
+ ## Cluster-expand on Conversation/Message clicks (cluster-integrity fix)
35
+
36
+ Clicking a Conversation node OR any Message node pulls the WHOLE
37
+ conversation cluster onto the canvas: the Conversation node itself plus
38
+ every Message belonging to it (via `PART_OF`), capped at 200 messages
39
+ for layout reasons. The arrow chain along the conversation (the `NEXT`
40
+ edges) renders for free because the inter-node relationship pass picks
41
+ up edges where both endpoints are in the visible window.
42
+
43
+ Pre-fix, clicking a middle Message expanded only its prev+next
44
+ neighbours; the head, tail, and Conversation node dropped off, visually
45
+ disintegrating the conversation. The new behaviour keeps the cluster
46
+ intact across click navigation. `PART_OF` edges are now rendered between
47
+ visible Conversation/Message pairs (previously suppressed because they
48
+ "added no information when the Conversation node wasn't on canvas" — an
49
+ assumption that broke the moment the cluster-expand put it there).
50
+
51
+ The breadcrumb above the canvas tracks each pivot — every entry except
52
+ the last is clickable to pop the view-stack back to that point.
53
+
34
54
  ## Tooltips and side panel
35
55
 
36
56
  Hovering a node still shows the full 5-line tooltip (display name, labels,
@@ -467,6 +467,22 @@ grep '[persist] tool-call persisted' server.log | tail -10
467
467
 
468
468
  Each log entry includes the tool name and a truncated conversation ID for correlation.
469
469
 
470
+ ## Process provenance — durable actions emit Tasks
471
+
472
+ Every durable action — onboarding, cloudflare tunnel-login, brand publish, future deterministic flows — emits a `:Task {kind:"<flow>"}` node carrying the action's lifecycle and a `:PRODUCED` edge to every entity the action created. This makes the graph traversable from the originating Conversation to every entity created during it via `(c)<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e)` — answering "what did this turn produce" in one Cypher hop.
473
+
474
+ The doctrine is enforced at the storage primitive: writes to `:Person`, `:UserProfile`, `:AdminUser`, `:Organization`, `:LocalBusiness`, `:CloudflareTunnel`, or `:CloudflareHostname` MUST include exactly one inbound `:PRODUCED` edge whose source is a `:Task`. Bootstrap writes (PIN-setup, schema migrations, lazy `loadUserProfile`) are exempt via `createdBy.agent === 'system'`.
475
+
476
+ Two surfaces emit the lifecycle: agent-driven actions call `task-create`/`task-update`/`task-complete` over MCP (`task-create` accepts `kind`, `inputsProvided` — names only, never values — and `raisedDuringConversationKey` to resolve the `RAISED_DURING` edge). Shell-driven actions wrap their script invocation in [platform/ui/app/lib/cloudflare-task-tracker.ts](../../../ui/app/lib/cloudflare-task-tracker.ts) (cloudflare is the first; installer / brand-publish / OAuth-login deferred). Both surfaces emit the same `[task] action-start|step|done` log lines so operators can grep one channel uniformly.
477
+
478
+ `memory-write` accepts an optional `producedByTaskId` parameter. When set, an inbound `:PRODUCED` edge from that Task is composed into the write's relationships before the gate runs — the typical agent-side pattern is to call `task-create` at the start of a flow, capture `taskId`, and pass it as `producedByTaskId` on every subsequent `memory-write` for an action-provenance-gated label. The gate verifies Task and write share the same `accountId`; mismatch is rejected loud.
479
+
480
+ Operator audit cyphers:
481
+ - "What entities did this conversation's actions produce?" — `MATCH (c:AdminConversation {conversationId:$id})<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(e) RETURN labels(e), e.name, t.kind, t.status`
482
+ - "What cloudflare resources did this onboarding produce?" — `MATCH (t:Task {kind:'cloudflare-tunnel-login', status:'completed'})-[:PRODUCED]->(r) RETURN t.taskId, r.tunnelId, r.hostnameValue ORDER BY t.completedAt DESC`
483
+
484
+ See `.docs/neo4j.md § Process provenance doctrine` for the full enforcement contract, observability surface, and out-of-scope deferrals.
485
+
470
486
  ## Context compaction
471
487
 
472
488
  When an admin turn crosses 75% of the model's context window, {{productName}} runs a silent compaction turn that asks the agent to call the `session-compact` MCP tool with a structured briefing (what you asked for, what was done, decisions made, work-in-progress, things you've shared about yourself). The briefing is written to Neo4j; the next admin turn injects it back into the system prompt, so continuity survives across the compaction boundary without re-sending the full transcript.
@@ -20,6 +20,8 @@ metadata: {"platform":{}}
20
20
 
21
21
  Manages the agent's own dedicated email account — IMAP for reading, SMTP for sending. Admin agent only.
22
22
 
23
+ **Identity boundary (operator-identity fix).** This plugin manages the AGENT's email channel: an `:EmailAccount` node with IMAP/SMTP credentials, polling state, and the agent's `agentAddress`. It is NOT the operator-identity store. The operator's own email lives on the OWNS-bound `:Person` (`Person.email`) and is captured via `profile-update` with `personFields`. Confusing the two surfaces (writing the operator's personal email to `EmailAccount.agentAddress`, or vice versa) breaks routing AND identity coverage. See [.docs/agents.md § Coverage-driven elicitation bias] for the operator-identity contract.
24
+
23
25
  ## Capabilities
24
26
 
25
27
  - **Setup:** `email-setup` — collect credentials via rendered `form`, connect IMAP/SMTP. Supports alias addresses (`agentAddress`).
@@ -59,6 +59,12 @@ Ranking is ephemeral and contextual — nothing is written back to the graph. Wh
59
59
 
60
60
  Use `expandHops: 0` for listing and inventory queries (returns node properties only — compact output). Use `expandHops: 1` (default) for deep context queries where related nodes add value. Queries that combine a high `limit` with `expandHops: 1` on node types that have many relationships (e.g. KnowledgeDocument with HAS_SECTION) are most likely to trigger trimming.
61
61
 
62
+ ## Process provenance — `producedByTaskId`
63
+
64
+ `memory-write` accepts an optional `producedByTaskId` parameter. When set, an inbound `:PRODUCED` edge from that `:Task` node is composed into the write's `relationships` array before the storage gate runs. The gate verifies the Task and the new node share the same `accountId`; mismatch is rejected loud.
65
+
66
+ Writes targeting `:Person`, `:UserProfile`, `:AdminUser`, `:Organization`, `:LocalBusiness`, `:CloudflareTunnel`, or `:CloudflareHostname` REQUIRE this thread (or `createdBy.agent === 'system'` for bootstrap paths). The typical pattern: call `task-create` at the start of a flow with `kind` and `raisedDuringConversationKey`; capture the returned `taskId`; pass it as `producedByTaskId` on every subsequent `memory-write` for an action-provenance-gated label. See `.docs/neo4j.md § Process provenance doctrine`.
67
+
62
68
  ## Graph Hygiene
63
69
 
64
70
  Graph hygiene is **agent-directed, case by case** — no autonomous rule engine, no cron. The Neo4j graph degrades into a landfill if pollution from prior agent errors (wrong-account hostnames, fabricated identifiers, raw monologue dumps) is allowed to compound. Cleanup happens when the admin agent observes a match against the operator-curated deny-list (or the user directly asks) and applies discretion using `memory-update`, `memory-delete`, or `conversation-memory-expunge`.
@@ -415,7 +415,7 @@ server.tool("memory-rank", "Retrieve entities from the knowledge graph and rank
415
415
  }
416
416
  });
417
417
  if (!readOnly) {
418
- server.tool("memory-write", "Create a new node in the knowledge graph with automatic embedding computation. Write doctrine (Task 673): every node must be created with at least one relationship. Call memory-search first to find target elementIds.", {
418
+ server.tool("memory-write", "Create a new node in the knowledge graph with automatic embedding computation. Write doctrine (Task 673): every node must be created with at least one relationship. Call memory-search first to find target elementIds. Process-provenance (Task 885): writes targeting :Person, :UserProfile, :AdminUser, :Organization, :LocalBusiness, :CloudflareTunnel, or :CloudflareHostname require an inbound :PRODUCED edge from a :Task — pass the active Task's elementId as `producedByTaskId` and the edge will be composed automatically.", {
419
419
  labels: z
420
420
  .array(z.string())
421
421
  .describe("Node labels (e.g. ['Person'], ['Question'], ['DefinedTerm'])"),
@@ -433,7 +433,11 @@ if (!readOnly) {
433
433
  }))
434
434
  .min(1)
435
435
  .describe("Relationships to create with existing nodes. At least one is required — a node without an adjacency is noise, not knowledge."),
436
- }, async ({ labels, properties, scope, relationships }) => {
436
+ producedByTaskId: z
437
+ .string()
438
+ .optional()
439
+ .describe("Active Task elementId (Task 885). When set, an inbound :PRODUCED edge from this Task is composed into relationships before the write — required for action-provenance-gated labels (Person, UserProfile, AdminUser, Organization, LocalBusiness, CloudflareTunnel, CloudflareHostname). Task must share the same accountId; mismatch is rejected."),
440
+ }, async ({ labels, properties, scope, relationships, producedByTaskId }) => {
437
441
  try {
438
442
  const result = await memoryWrite({
439
443
  labels,
@@ -447,6 +451,7 @@ if (!readOnly) {
447
451
  tool: "memory-write",
448
452
  },
449
453
  validator,
454
+ producedByTaskId,
450
455
  });
451
456
  return {
452
457
  content: [
@@ -1582,7 +1587,7 @@ if (!readOnly) {
1582
1587
  await dbSession.close();
1583
1588
  }
1584
1589
  });
1585
- server.tool("profile-update", "Create, update, or reinforce a user preference, and set top-level UserProfile fields (timezone, locale, givenName, role, expertise) via the profileFields parameter. Supports modes: reinforce (re-observed, boost confidence), update (value changed), contradict (value contradicts prior), merge (combine overlapping preferences).", {
1590
+ server.tool("profile-update", "Create, update, or reinforce a user preference; set top-level UserProfile fields (timezone, locale, givenName, role, expertise) via profileFields; AND set operator-identity fields (email, telephone) on the operator's personal-profile Person via personFields (Task 886 §D — Person is the canonical operator-identity store; UserProfile carries preferences and behavioural state only). Supports modes: reinforce (re-observed, boost confidence), update (value changed), contradict (value contradicts prior), merge (combine overlapping preferences).", {
1586
1591
  category: z.enum(["communication", "scheduling", "decision", "workflow", "content", "interaction"])
1587
1592
  .describe("Preference category"),
1588
1593
  key: z.string().describe("Preference identifier (e.g. 'response_length', 'meeting_time')"),
@@ -1595,9 +1600,14 @@ if (!readOnly) {
1595
1600
  .describe("Conversation ID for evidence linking"),
1596
1601
  profileFields: z.record(z.string(), z.unknown()).optional()
1597
1602
  .describe("Top-level UserProfile fields to update (e.g. timezone, locale, role)"),
1603
+ personFields: z.object({
1604
+ email: z.string().optional(),
1605
+ telephone: z.string().optional(),
1606
+ }).optional()
1607
+ .describe("Operator-identity fields written to the OWNS-bound Person (Task 886 §D). Use canonical `telephone` (NOT `phone` — that is the schema synonym, not the canonical name). Tool throws if the AdminUser-OWNS-Person edge is missing rather than silently no-op."),
1598
1608
  mergeSourceIds: z.array(z.string()).optional()
1599
1609
  .describe("For mode 'merge': preferenceIds of sources to combine into this preference"),
1600
- }, async ({ category, key, value, source, mode, conversationId, profileFields, mergeSourceIds }) => {
1610
+ }, async ({ category, key, value, source, mode, conversationId, profileFields, personFields, mergeSourceIds }) => {
1601
1611
  try {
1602
1612
  if (!userId) {
1603
1613
  return { content: [{ type: "text", text: "profile-update requires an authenticated admin session with userId" }], isError: true };
@@ -1612,6 +1622,7 @@ if (!readOnly) {
1612
1622
  mode,
1613
1623
  conversationId,
1614
1624
  profileFields: profileFields,
1625
+ personFields,
1615
1626
  mergeSourceIds,
1616
1627
  });
1617
1628
  return {