@rubytech/create-maxy 1.0.880 → 1.0.883

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 (60) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.js +57 -9
  3. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.js.map +1 -1
  4. package/payload/platform/lib/graph-write/dist/conversation-provenance.d.ts +26 -0
  5. package/payload/platform/lib/graph-write/dist/conversation-provenance.d.ts.map +1 -0
  6. package/payload/platform/lib/graph-write/dist/conversation-provenance.js +81 -0
  7. package/payload/platform/lib/graph-write/dist/conversation-provenance.js.map +1 -0
  8. package/payload/platform/lib/graph-write/dist/index.d.ts +38 -16
  9. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  10. package/payload/platform/lib/graph-write/dist/index.js +75 -35
  11. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  12. package/payload/platform/lib/graph-write/src/__tests__/action-provenance-gate.test.ts +59 -9
  13. package/payload/platform/lib/graph-write/src/conversation-provenance.ts +140 -0
  14. package/payload/platform/lib/graph-write/src/index.ts +76 -35
  15. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  16. package/payload/platform/plugins/admin/mcp/dist/__tests__/plugin-read-skill-resolution.test.js +26 -2
  17. package/payload/platform/plugins/admin/mcp/dist/__tests__/plugin-read-skill-resolution.test.js.map +1 -1
  18. package/payload/platform/plugins/admin/mcp/dist/index.js +8 -6
  19. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  20. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.d.ts +5 -1
  21. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.d.ts.map +1 -1
  22. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.js +24 -8
  23. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.js.map +1 -1
  24. package/payload/platform/plugins/admin/skills/plainly/SKILL.md +105 -0
  25. package/payload/platform/plugins/admin/skills/plainly/references/worked-examples.md +301 -0
  26. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  27. package/payload/platform/plugins/contacts/PLUGIN.md +8 -0
  28. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.d.ts.map +1 -1
  29. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.js +17 -2
  30. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.js.map +1 -1
  31. package/payload/platform/plugins/docs/references/internals.md +2 -2
  32. package/payload/platform/plugins/docs/references/platform.md +5 -1
  33. package/payload/platform/plugins/docs/references/plugins-guide.md +3 -1
  34. package/payload/platform/plugins/memory/PLUGIN.md +5 -3
  35. package/payload/platform/plugins/memory/mcp/dist/index.js +2 -2
  36. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  37. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  38. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +18 -1
  39. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  40. package/payload/platform/scripts/__tests__/logs-read-prefix.sh +341 -0
  41. package/payload/platform/scripts/logs-read.sh +108 -41
  42. package/payload/platform/scripts/logs-read.test.sh +6 -2
  43. package/payload/platform/templates/agents/admin/IDENTITY.md +6 -2
  44. package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
  45. package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
  46. package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
  47. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
  48. package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
  49. package/payload/platform/templates/specialists/agents/research-assistant.md +6 -0
  50. package/payload/premium-plugins/real-agency/BUNDLE.md +1 -1
  51. package/payload/server/chunk-ECAQVMRA.js +759 -0
  52. package/payload/server/chunk-K7S5T4VG.js +11534 -0
  53. package/payload/server/chunk-KMVUUVHM.js +11438 -0
  54. package/payload/server/chunk-LVC7NKZ2.js +689 -0
  55. package/payload/server/cloudflare-task-tracker-CY6QL6CY.js +22 -0
  56. package/payload/server/cloudflare-task-tracker-JNZXLW32.js +22 -0
  57. package/payload/server/maxy-edge.js +2 -1
  58. package/payload/server/public/assets/{admin-CCEuBnaK.js → admin-BN_z-2Bm.js} +2 -2
  59. package/payload/server/public/index.html +1 -1
  60. package/payload/server/server.js +115 -8
@@ -94,11 +94,12 @@ test("ACTION_PROVENANCE_LABELS includes the documented set", () => {
94
94
  }
95
95
  });
96
96
 
97
- test("writeNodeWithEdges rejects :Person write missing PRODUCED-from-Task edge", async () => {
97
+ test("writeNodeWithEdges rejects :Person write missing any inbound PRODUCED edge (missing-provenance)", async () => {
98
98
  const { session } = makeStubSession({
99
- preCheckLabelsByTarget: new Map([["other-target", ["Conversation"]]]),
99
+ preCheckLabelsByTarget: new Map([["other-target", ["LocalBusiness"]]]),
100
100
  });
101
101
  const relationships: GraphRelationship[] = [
102
+ // No inbound PRODUCED at all — neither Task nor Conversation nor Message.
102
103
  { type: "PART_OF", direction: "outgoing", targetNodeId: "other-target" },
103
104
  ];
104
105
  await assert.rejects(
@@ -112,7 +113,7 @@ test("writeNodeWithEdges rejects :Person write missing PRODUCED-from-Task edge",
112
113
  createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" },
113
114
  expectedAccountId: TEST_ACCOUNT_ID,
114
115
  }),
115
- /missing-action-provenance|Process provenance/i,
116
+ /missing-provenance/i,
116
117
  );
117
118
  });
118
119
 
@@ -156,16 +157,65 @@ test("writeNodeWithEdges accepts :Person write with no Task edge when agent='sys
156
157
  assert.equal(res.edgesCreated, 1);
157
158
  });
158
159
 
159
- test("writeNodeWithEdges rejects :Person write whose 'PRODUCED-incoming' source is NOT a :Task", async () => {
160
+ // Task 997 gate widened to accept :Conversation provenance for direct
161
+ // admin asks. The wrapper auto-injects the inbound PRODUCED edge from the
162
+ // active :AdminConversation via CONVERSATION_NODE_ID.
163
+ test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Conversation edge", async () => {
160
164
  const { session } = makeStubSession({
161
165
  preCheckLabelsByTarget: new Map([
162
- ["other-id", ["Conversation"]],
166
+ // AdminConversation carries the parent :Conversation label too.
167
+ ["conv-id", ["Conversation", "AdminConversation"]],
163
168
  ]),
164
169
  });
165
170
  const relationships: GraphRelationship[] = [
166
- // Inbound PRODUCED edge but the source is a Conversation, not a Task.
167
- // Must reject — prevents bypass via spoofed PRODUCED edge from any node.
168
- { type: "PRODUCED", direction: "incoming", targetNodeId: "other-id" },
171
+ { type: "PRODUCED", direction: "incoming", targetNodeId: "conv-id" },
172
+ ];
173
+ const res = await writeNodeWithEdges({
174
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
175
+ session: session as any,
176
+ labels: ["Person"],
177
+ props: { accountId: TEST_ACCOUNT_ID },
178
+ relationships,
179
+ createdBy: { agent: "maxy-admin", session: "sess-1", tool: "contact-create" },
180
+ expectedAccountId: TEST_ACCOUNT_ID,
181
+ });
182
+ assert.equal(res.edgesCreated, 1);
183
+ });
184
+
185
+ // Task 997 — Message subtypes (UserMessage, AssistantMessage, AdminMessage)
186
+ // also qualify because the labels() array includes the parent :Message label.
187
+ test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Message edge", async () => {
188
+ const { session } = makeStubSession({
189
+ preCheckLabelsByTarget: new Map([
190
+ ["msg-id", ["Message", "UserMessage"]],
191
+ ]),
192
+ });
193
+ const relationships: GraphRelationship[] = [
194
+ { type: "PRODUCED", direction: "incoming", targetNodeId: "msg-id" },
195
+ ];
196
+ const res = await writeNodeWithEdges({
197
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
+ session: session as any,
199
+ labels: ["Person"],
200
+ props: { accountId: TEST_ACCOUNT_ID },
201
+ relationships,
202
+ createdBy: { agent: "maxy-admin", session: "sess-1", tool: "contact-create" },
203
+ expectedAccountId: TEST_ACCOUNT_ID,
204
+ });
205
+ assert.equal(res.edgesCreated, 1);
206
+ });
207
+
208
+ // Task 997 — defence-in-depth: the gate's source-label allowlist still
209
+ // rejects PRODUCED edges from nodes outside {Task, Conversation, Message}.
210
+ // Prevents bypass via a spoofed PRODUCED edge from any unrelated node.
211
+ test("writeNodeWithEdges rejects :Person write with PRODUCED edge from a non-provenance-source label", async () => {
212
+ const { session } = makeStubSession({
213
+ preCheckLabelsByTarget: new Map([
214
+ ["business-id", ["LocalBusiness"]],
215
+ ]),
216
+ });
217
+ const relationships: GraphRelationship[] = [
218
+ { type: "PRODUCED", direction: "incoming", targetNodeId: "business-id" },
169
219
  ];
170
220
  await assert.rejects(
171
221
  () =>
@@ -178,7 +228,7 @@ test("writeNodeWithEdges rejects :Person write whose 'PRODUCED-incoming' source
178
228
  createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" },
179
229
  expectedAccountId: TEST_ACCOUNT_ID,
180
230
  }),
181
- /missing-action-provenance|Process provenance/i,
231
+ /missing-provenance/i,
182
232
  );
183
233
  });
184
234
 
@@ -0,0 +1,140 @@
1
+ // Task 997 — direct-ask provenance auto-injection.
2
+ //
3
+ // MCP tool wrappers that write to ACTION_PROVENANCE_LABELS call this helper
4
+ // before `writeNodeWithEdges`. When the admin server has stamped
5
+ // `CONVERSATION_NODE_ID` in the MCP spawn env (the elementId of the active
6
+ // `:AdminConversation`), the helper:
7
+ // - verifies the source node exists, carries one of PROVENANCE_SOURCE_LABELS,
8
+ // and shares the write's accountId
9
+ // - prepends a synthetic inbound `:PRODUCED` edge to the relationships array
10
+ // so the gate inside `writeNodeWithEdges` accepts the write
11
+ //
12
+ // The injection is a no-op when:
13
+ // - the env var is unset (typical for autonomous workflow flows that pass an
14
+ // explicit `producedByTaskId` instead)
15
+ // - the labels being written do not require action provenance
16
+ // - the relationships array already carries an inbound `:PRODUCED` edge from
17
+ // any source (idempotency — explicit `producedByTaskId` already prepended
18
+ // one; a second source is redundant noise. If the caller's PRODUCED edge
19
+ // resolves to a node NOT in PROVENANCE_SOURCE_LABELS, the downstream gate
20
+ // rejects with `missing-provenance` — defence-in-depth covers that case)
21
+ //
22
+ // Loud-failure cases emit `[<logNamespace>] [provenance-missing] reason=<r>`
23
+ // to stderr and return the original relationships array unchanged — the
24
+ // downstream gate then fires `missing-provenance` so both observability
25
+ // signals land in server.log.
26
+
27
+ import type { Session } from "neo4j-driver";
28
+ import {
29
+ ACTION_PROVENANCE_LABELS,
30
+ PROVENANCE_SOURCE_LABELS,
31
+ } from "./index.js";
32
+ import type { GraphRelationship } from "./index.js";
33
+
34
+ export interface InjectConversationProvenanceParams {
35
+ session: Session;
36
+ /** The relationships array the caller is about to pass to `writeNodeWithEdges`. */
37
+ relationships: readonly GraphRelationship[];
38
+ /** The accountId of the write — must match the source node's accountId. */
39
+ accountId: string;
40
+ /** Labels of the node about to be written. Used to skip injection when provenance is not required. */
41
+ writeLabels: readonly string[];
42
+ /**
43
+ * Value of `process.env.CONVERSATION_NODE_ID` at call time. Passed as a
44
+ * parameter so tests can supply it without mutating process.env.
45
+ */
46
+ conversationNodeId: string | undefined;
47
+ /**
48
+ * Log namespace prefix — typically `"mcp:contacts"` or `"mcp:memory"`. Used
49
+ * to format the `[provenance-inject]` / `[provenance-missing]` lines so the
50
+ * operator can attribute the signal to a plugin.
51
+ */
52
+ logNamespace: string;
53
+ /** Tool name (e.g. "contact-create") for the inject/missing log lines. */
54
+ tool: string;
55
+ }
56
+
57
+ export async function injectConversationProvenance(
58
+ params: InjectConversationProvenanceParams,
59
+ ): Promise<GraphRelationship[]> {
60
+ const {
61
+ session,
62
+ relationships,
63
+ accountId,
64
+ writeLabels,
65
+ conversationNodeId,
66
+ logNamespace,
67
+ tool,
68
+ } = params;
69
+
70
+ const original = [...relationships];
71
+
72
+ if (!writeLabels.some((l) => ACTION_PROVENANCE_LABELS.has(l))) return original;
73
+ if (!conversationNodeId) return original;
74
+ if (hasInboundProducedEdge(original)) return original;
75
+
76
+ let lookup;
77
+ try {
78
+ lookup = await session.run(
79
+ `MATCH (n) WHERE elementId(n) = $id RETURN labels(n) AS labels, n.accountId AS accountId LIMIT 1`,
80
+ { id: conversationNodeId },
81
+ );
82
+ } catch (err) {
83
+ process.stderr.write(
84
+ `[${logNamespace}] [provenance-missing] tool=${tool} reason=driver-error message=${err instanceof Error ? err.message : String(err)} conversationNodeId=${conversationNodeId}\n`,
85
+ );
86
+ return original;
87
+ }
88
+
89
+ if (lookup.records.length === 0) {
90
+ process.stderr.write(
91
+ `[${logNamespace}] [provenance-missing] tool=${tool} reason=node-not-found conversationNodeId=${conversationNodeId}\n`,
92
+ );
93
+ return original;
94
+ }
95
+
96
+ const labels = lookup.records[0].get("labels") as string[];
97
+ const sourceAccountId = lookup.records[0].get("accountId") as string;
98
+ const sourceLabel = labels.find((l) => PROVENANCE_SOURCE_LABELS.has(l));
99
+
100
+ if (!sourceLabel) {
101
+ process.stderr.write(
102
+ `[${logNamespace}] [provenance-missing] tool=${tool} reason=wrong-source-label labels=${labels.join(",")} conversationNodeId=${conversationNodeId}\n`,
103
+ );
104
+ return original;
105
+ }
106
+
107
+ if (sourceAccountId !== accountId) {
108
+ process.stderr.write(
109
+ `[${logNamespace}] [provenance-missing] tool=${tool} reason=account-mismatch sourceAccountId=${sourceAccountId.slice(0, 8)} writeAccountId=${accountId.slice(0, 8)} conversationNodeId=${conversationNodeId}\n`,
110
+ );
111
+ return original;
112
+ }
113
+
114
+ process.stderr.write(
115
+ `[${logNamespace}] [provenance-inject] tool=${tool} from=${sourceLabel}:${conversationNodeId}\n`,
116
+ );
117
+
118
+ return [
119
+ {
120
+ type: "PRODUCED",
121
+ direction: "incoming",
122
+ targetNodeId: conversationNodeId,
123
+ },
124
+ ...original,
125
+ ];
126
+ }
127
+
128
+ /**
129
+ * Detect any pre-existing inbound `:PRODUCED` edge in the relationships
130
+ * array. Source-label validation is the downstream gate's job; here we only
131
+ * need to know whether the caller already provided one — if so, skip
132
+ * injection to avoid duplicate edges.
133
+ */
134
+ function hasInboundProducedEdge(
135
+ relationships: readonly GraphRelationship[],
136
+ ): boolean {
137
+ return relationships.some(
138
+ (r) => r.type === "PRODUCED" && r.direction === "incoming",
139
+ );
140
+ }
@@ -3,6 +3,11 @@
3
3
  // `[graph-*]` log family colocated.
4
4
  export * from "./audit.js";
5
5
 
6
+ // Task 997: re-export the conversation-provenance injector. MCP wrappers
7
+ // (contacts, memory) import it from the package root so the helper, the
8
+ // gate, and the label set live in one import surface.
9
+ export * from "./conversation-provenance.js";
10
+
6
11
  /**
7
12
  * Write doctrine (Task 673): a node without at least one adjacency is noise,
8
13
  * not knowledge. `writeNodeWithEdges` is the single primitive every graph
@@ -13,21 +18,33 @@ export * from "./audit.js";
13
18
  * maps, and flat props are queryable — `MATCH (n) WHERE n.createdBySession
14
19
  * = $id RETURN n` is the forensic entry point).
15
20
  *
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.
21
+ * Process provenance doctrine (Task 885, widened by Task 997): writes
22
+ * targeting any label in `ACTION_PROVENANCE_LABELS` (Person, UserProfile,
23
+ * AdminUser, Organization, LocalBusiness, CloudflareTunnel,
24
+ * CloudflareHostname) must include at least one inbound `:PRODUCED` edge
25
+ * whose source carries one of `:Task`, `:Conversation`, or `:Message` (the
26
+ * label set is checked against the actual `labels()` of the target node, so
27
+ * subtype labels like `:AdminConversation`, `:UserMessage`, `:AssistantMessage`,
28
+ * and `:AdminMessage` all qualify). This makes every durable LLM-tool-driven
29
+ * entity creation traversable from either:
30
+ * - the Task that produced it (autonomous workflows: onboarding skill,
31
+ * cloudflare tunnel-login, etc.), or
32
+ * - the Conversation/Message turn that triggered it (direct admin asks —
33
+ * "add Anneke Roux as person" stamps a `:PRODUCED` edge from the active
34
+ * `:AdminConversation` so the entity is traceable to its causal turn
35
+ * without forcing the agent to mint a synthetic `:Task` per ask).
36
+ * Task 997 broadened the gate after the Task-only requirement created
37
+ * redundant-Task bloat for direct admin asks; the Task-mediated path stays
38
+ * canonical for autonomous workflows. Bootstrap writes
39
+ * (`createdBy.agent === 'system'`) are exempt — installer / migration /
40
+ * lazy-create paths run as system writers.
24
41
  *
25
42
  * Rejection paths (every one emits a stderr log the admin server pipes to
26
43
  * server.log, so orphan pressure is visible per-write not just in the
27
44
  * hourly [graph-health] signal):
28
45
  * - zero relationships → `[graph-write] reject reason=zero-relationships`
29
46
  * - unresolved target id → `[graph-write] reject reason=unresolved-target`
30
- * - missing action provenance → `[graph-write] reject reason=missing-action-provenance`
47
+ * - missing provenance → `[graph-write] reject reason=missing-provenance`
31
48
  * - removed-feature write → `[graph-write] reject reason=removed-feature`
32
49
  * (`:ReviewAlert` and `:Event {actionTool:"review-digest-compose"}` are
33
50
  * deleted features; the gate catches doctrine-compliant writers
@@ -42,16 +59,18 @@ export * from "./audit.js";
42
59
  */
43
60
 
44
61
  /**
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`).
62
+ * Labels that require an inbound `:PRODUCED` edge from a `:Task`,
63
+ * `:Conversation`, or `:Message` (Task 885, widened by Task 997). Bootstrap
64
+ * writes with `createdBy.agent === 'system'` are exempt — that carve-out
65
+ * covers PIN-setup `writeAdminUserAndPerson`, schema migrations, and the
66
+ * lazy `loadUserProfile` MERGE path that bypasses this primitive entirely
67
+ * (raw Cypher in `platform/ui/app/lib/neo4j-store.ts`).
50
68
  *
51
69
  * The set is intentionally not exhaustive — labels added here become a
52
70
  * 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'`.
71
+ * after every legitimate writer of it can either (a) carry a PRODUCED edge
72
+ * from one of the accepted source families, or (b) declare itself as
73
+ * `agent: 'system'`.
55
74
  */
56
75
  export const ACTION_PROVENANCE_LABELS: ReadonlySet<string> = new Set([
57
76
  "Person",
@@ -63,6 +82,18 @@ export const ACTION_PROVENANCE_LABELS: ReadonlySet<string> = new Set([
63
82
  "CloudflareHostname",
64
83
  ]);
65
84
 
85
+ /**
86
+ * Source labels that satisfy the inbound-PRODUCED gate. The check is
87
+ * performed against the full `labels()` array of the target node, so
88
+ * subtype labels (`:AdminConversation`, `:UserMessage`, etc.) qualify
89
+ * because they carry the parent `:Conversation` / `:Message` label too.
90
+ */
91
+ export const PROVENANCE_SOURCE_LABELS: ReadonlySet<string> = new Set([
92
+ "Task",
93
+ "Conversation",
94
+ "Message",
95
+ ]);
96
+
66
97
  function requiresActionProvenance(labels: readonly string[]): boolean {
67
98
  for (const label of labels) {
68
99
  if (ACTION_PROVENANCE_LABELS.has(label)) return true;
@@ -70,7 +101,7 @@ function requiresActionProvenance(labels: readonly string[]): boolean {
70
101
  return false;
71
102
  }
72
103
 
73
- function findProducedFromTaskCandidates(
104
+ function findProvenanceCandidates(
74
105
  relationships: readonly GraphRelationship[],
75
106
  ): GraphRelationship[] {
76
107
  return relationships.filter(
@@ -239,32 +270,42 @@ export async function writeNodeWithEdges(
239
270
  );
240
271
  }
241
272
 
242
- // Process provenance gate (Task 885). Labels in
243
- // ACTION_PROVENANCE_LABELS require a `:Task` source on at least one
244
- // inbound :PRODUCED edge so every durable LLM-tool entity write is
245
- // traversable from the Task that produced it. Bootstrap writes
246
- // (createdBy.agent === 'system') are exempt installer + migration
247
- // paths predate any Task. The check runs INSIDE the transaction so
248
- // the labels-by-target map is consistent with target resolution.
249
- let producedByTaskId: string | null = null;
273
+ // Process provenance gate (Task 885, widened by Task 997). Labels in
274
+ // ACTION_PROVENANCE_LABELS require an inbound :PRODUCED edge from a
275
+ // node carrying one of PROVENANCE_SOURCE_LABELS (:Task, :Conversation,
276
+ // :Message subtype labels qualify via the labels() array). This
277
+ // makes every durable LLM-tool entity write traversable to either the
278
+ // Task that produced it (autonomous workflows) or the
279
+ // Conversation/Message turn that triggered it (direct admin asks).
280
+ // Bootstrap writes (createdBy.agent === 'system') are exempt —
281
+ // installer + migration paths predate any provenance node. The check
282
+ // runs INSIDE the transaction so the labels-by-target map is
283
+ // consistent with target resolution.
284
+ let provenanceSourceId: string | null = null;
285
+ let provenanceSourceLabel: string | null = null;
250
286
  if (
251
287
  requiresActionProvenance(labels) &&
252
288
  (createdBy.agent ?? "") !== "system"
253
289
  ) {
254
- const candidates = findProducedFromTaskCandidates(relationships);
255
- const taskCandidates = candidates.filter((r) => {
256
- const lbls = labelsByTarget.get(r.targetNodeId);
257
- return Array.isArray(lbls) && lbls.includes("Task");
258
- });
259
- if (taskCandidates.length === 0) {
290
+ const candidates = findProvenanceCandidates(relationships);
291
+ const matched = candidates
292
+ .map((r) => {
293
+ const lbls = labelsByTarget.get(r.targetNodeId);
294
+ if (!Array.isArray(lbls)) return null;
295
+ const sourceLabel = lbls.find((l) => PROVENANCE_SOURCE_LABELS.has(l));
296
+ return sourceLabel ? { rel: r, sourceLabel } : null;
297
+ })
298
+ .filter((m): m is { rel: GraphRelationship; sourceLabel: string } => m !== null);
299
+ if (matched.length === 0) {
260
300
  process.stderr.write(
261
- `[graph-write] reject reason=missing-action-provenance labels=${labelCsv} agent=${agentLabel}\n`
301
+ `[graph-write] reject reason=missing-provenance labels=${labelCsv} agent=${agentLabel}\n`
262
302
  );
263
303
  throw new Error(
264
- `missing-action-provenance: write to ${labelCsv} requires an inbound :PRODUCED edge from a :Task (createdBy.agent='${agentLabel}'). Pass producedByTaskId on the write call task-create at the start of the flow and thread the returned taskId through every subsequent memory-write for one of these labels.`
304
+ `missing-provenance: write to ${labelCsv} requires an inbound :PRODUCED edge from a :Task, :Conversation, or :Message (createdBy.agent='${agentLabel}'). Either pass producedByTaskId on the write (autonomous workflows: call task-create at the start of the flow and thread the returned taskId), or rely on the MCP wrapper's CONVERSATION_NODE_ID env-stamp injection (direct admin asks).`
265
305
  );
266
306
  }
267
- producedByTaskId = taskCandidates[0].targetNodeId;
307
+ provenanceSourceId = matched[0].rel.targetNodeId;
308
+ provenanceSourceLabel = matched[0].sourceLabel;
268
309
  }
269
310
 
270
311
  let nodeRes;
@@ -337,7 +378,7 @@ export async function writeNodeWithEdges(
337
378
  }
338
379
 
339
380
  process.stderr.write(
340
- `[graph-write] accepted labels=${labelCsv} edges=${edgesCreated} createdByAgent=${createdBy.agent ?? "unknown"} createdByTool=${createdBy.tool ?? createdBy.source ?? "unknown"} producedByTask=${producedByTaskId ?? "none"}\n`
381
+ `[graph-write] accepted labels=${labelCsv} edges=${edgesCreated} createdByAgent=${createdBy.agent ?? "unknown"} createdByTool=${createdBy.tool ?? createdBy.source ?? "unknown"} producedBy=${provenanceSourceLabel ?? "none"}:${provenanceSourceId ?? "none"}\n`
341
382
  );
342
383
 
343
384
  return { nodeId, labels: nodeLabels, edgesCreated };
@@ -68,6 +68,7 @@ Tools are available via the `admin` MCP server.
68
68
  | Manage plugins and settings | User asks to install, enable, disable, or configure plugins, or change account settings | `skills/plugin-management/SKILL.md` |
69
69
  | Manage specialists | User asks to install or remove a specialist subagent, or activate/deactivate premium plugin agents | `skills/specialist-management/SKILL.md` |
70
70
  | Generate print-quality PDF | User asks to create a PDF document, one-pager, brochure, or any HTML intended for print/download | `skills/a4-print-documents/SKILL.md` |
71
+ | Plain-English explanation | User asks to explain, define, or pre-empts ("explain in plain English", "what does X mean", "define X", "I don't understand", "what is this"), or admin is about to return a reply containing a term not in the operator's prior turn | `skills/plainly/SKILL.md` |
71
72
 
72
73
  ## Hooks
73
74
 
@@ -53,15 +53,39 @@ describe("findSkillOwners", () => {
53
53
  });
54
54
  });
55
55
  describe("computePluginReadHint", () => {
56
- it("returns the owning plugin name when exactly one sibling owns the skill", () => {
56
+ it("returns the owner and canonical SKILL.md path when one sibling owns the skill referenced via file", () => {
57
57
  seedSkill("cloudflare", "setup-tunnel");
58
58
  const hint = computePluginReadHint(root, "admin", "skills/setup-tunnel/SKILL.md");
59
- expect(hint).toBe("cloudflare");
59
+ expect(hint).toEqual({ pluginName: "cloudflare", file: "skills/setup-tunnel/SKILL.md" });
60
60
  });
61
61
  it("returns null when no plugin owns the requested skill", () => {
62
62
  seedSkill("admin", "onboarding");
63
63
  const hint = computePluginReadHint(root, "admin", "skills/imaginary/SKILL.md");
64
64
  expect(hint).toBeNull();
65
65
  });
66
+ // Task 1000 — wrong-`pluginName` shape: the skill slug is buried inside
67
+ // pluginName instead of the file path. The hint must still surface both
68
+ // the corrected pluginName and the canonical SKILL.md file path.
69
+ it("hints when skills/<slug> is buried in pluginName and file defaults to PLUGIN.md", () => {
70
+ seedSkill("admin", "plainly");
71
+ const hint = computePluginReadHint(root, "admin/skills/plainly", "PLUGIN.md");
72
+ expect(hint).toEqual({ pluginName: "admin", file: "skills/plainly/SKILL.md" });
73
+ });
74
+ it("hints when skills/<slug> is buried in pluginName and file is the bare SKILL.md", () => {
75
+ seedSkill("admin", "plainly");
76
+ const hint = computePluginReadHint(root, "admin/skills/plainly", "SKILL.md");
77
+ expect(hint).toEqual({ pluginName: "admin", file: "skills/plainly/SKILL.md" });
78
+ });
79
+ it("hints with the real owner when the pluginName prefix is wrong (contacts/skills/plainly → admin)", () => {
80
+ seedSkill("admin", "plainly");
81
+ const hint = computePluginReadHint(root, "contacts/skills/plainly", "PLUGIN.md");
82
+ expect(hint).toEqual({ pluginName: "admin", file: "skills/plainly/SKILL.md" });
83
+ });
84
+ it("returns null when the skill name extracted from pluginName is ambiguous across plugins", () => {
85
+ seedSkill("admin", "plainly");
86
+ seedSkill("contacts", "plainly");
87
+ const hint = computePluginReadHint(root, "admin/skills/plainly", "PLUGIN.md");
88
+ expect(hint).toBeNull();
89
+ });
66
90
  });
67
91
  //# sourceMappingURL=plugin-read-skill-resolution.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin-read-skill-resolution.test.js","sourceRoot":"","sources":["../../src/__tests__/plugin-read-skill-resolution.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"plugin-read-skill-resolution.test.js","sourceRoot":"","sources":["../../src/__tests__/plugin-read-skill-resolution.test.ts"],"names":[],"mappings":"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"}
@@ -1622,12 +1622,14 @@ server.tool("plugin-read", "Read a plugin definition (PLUGIN.md) or one of its r
1622
1622
  const resolvedFile = file ?? "PLUGIN.md";
1623
1623
  const pluginPath = resolve(PLATFORM_ROOT, "plugins", pluginName, resolvedFile);
1624
1624
  if (!existsSync(pluginPath)) {
1625
- // Task 964 — when the agent guesses the wrong pluginName for a file
1626
- // path shaped like `skills/<n>/...`, surface the owning plugin so
1627
- // the next call is deterministic instead of falling through to Glob.
1628
- const owner = computePluginReadHint(PLATFORM_ROOT, pluginName, resolvedFile);
1629
- const hintText = owner ? ` Did you mean pluginName="${owner}"?` : "";
1630
- console.log(`[plugin-read] ${pluginName}/${resolvedFile} not-found hint=${owner ?? "none"}`);
1625
+ // Task 964/1000 — when the agent's pluginName/file combination misses
1626
+ // a skill that DOES live under `plugins/*/skills/<slug>/SKILL.md`,
1627
+ // surface the corrected pluginName AND the canonical file path so
1628
+ // the next call is deterministic. Both wrong-`file` and wrong-`pluginName`
1629
+ // shapes feed through `computePluginReadHint`.
1630
+ const hint = computePluginReadHint(PLATFORM_ROOT, pluginName, resolvedFile);
1631
+ const hintText = hint ? ` Did you mean pluginName="${hint.pluginName}", file="${hint.file}"?` : "";
1632
+ console.log(`[plugin-read] ${pluginName}/${resolvedFile} not-found hint=${hint ? hint.pluginName : "none"}`);
1631
1633
  return {
1632
1634
  content: [{ type: "text", text: `Plugin file not found: ${pluginPath}.${hintText}` }],
1633
1635
  isError: true,