@rubytech/create-maxy 1.0.793 → 1.0.795
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.
- package/package.json +1 -1
- package/payload/platform/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +8 -0
- package/payload/platform/neo4j/edge-annotations.json +20 -0
- package/payload/platform/neo4j/migrations/002-project-public-agents.ts +191 -0
- package/payload/platform/neo4j/schema.cypher +69 -2
- package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-gate.test.sh +166 -0
- package/payload/platform/plugins/admin/hooks/archive-ingest-gate.sh +147 -0
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +5 -2
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts +2 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js.map +1 -1
- package/payload/platform/plugins/whatsapp-import/PLUGIN.md +4 -0
- package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts +8 -2
- package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts.map +1 -1
- package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js +66 -15
- package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js.map +1 -1
- package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/parse-export.test.ts +175 -0
- package/payload/platform/plugins/whatsapp-import/lib/src/parse-export.ts +78 -17
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +2 -0
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +8 -6
- package/payload/platform/scripts/seed-neo4j.sh +43 -20
- package/payload/platform/templates/specialists/agents/database-operator.md +2 -0
- package/payload/server/chunk-2N7XJW6Q.js +3428 -0
- package/payload/server/chunk-3SQJW5Y5.js +9892 -0
- package/payload/server/client-pool-CTMWNDMO.js +28 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/public/assets/{Checkbox-DHsoNPeM.js → Checkbox-BruL6MSR.js} +1 -1
- package/payload/server/public/assets/{admin-CBDpia8P.js → admin-D8wbpnrW.js} +7 -7
- package/payload/server/public/assets/data-BhrQjgR5.js +1 -0
- package/payload/server/public/assets/graph-Jj7seS-w.js +1 -0
- package/payload/server/public/assets/{jsx-runtime-lOmSwjvd.css → jsx-runtime-foO6ZMix.css} +1 -1
- package/payload/server/public/assets/{page-DU8F3OGU.js → page-DIG7s5Jp.js} +1 -1
- package/payload/server/public/assets/page-sZb3wcOM.js +50 -0
- package/payload/server/public/assets/{public-Bn-gEWOv.js → public-CfjzDdUe.js} +1 -1
- package/payload/server/public/assets/{share-2-0IDKUUq9.js → share-2-BndjMKeG.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-B1S_t3Hq.js → useVoiceRecorder-D_8P7xJU.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +123 -125
- package/payload/server/public/assets/data-bIkywng-.js +0 -1
- package/payload/server/public/assets/graph-CT4W30GR.js +0 -1
- package/payload/server/public/assets/page-Cs2i--Z2.js +0 -50
- /package/payload/server/public/assets/{jsx-runtime-Br2bU3EJ.js → jsx-runtime-DJER3a7U.js} +0 -0
package/package.json
CHANGED
|
@@ -85,6 +85,14 @@ const CANONICAL_TEXT_PROPERTIES = [
|
|
|
85
85
|
"contactValue",
|
|
86
86
|
// ToolCall
|
|
87
87
|
"toolName",
|
|
88
|
+
// Agent (Task 837) — public-agent projection. `displayName` mirrors
|
|
89
|
+
// config.json; `slug` is the directory-name identifier; `role` is the
|
|
90
|
+
// discriminator carried on the four owned :KnowledgeDocument projections
|
|
91
|
+
// ('identity' | 'soul' | 'knowledge' | 'knowledge-summary'). Adding any
|
|
92
|
+
// new agent-side text property to the projector requires extending both
|
|
93
|
+
// the schema's ON EACH list and this canon, otherwise BM25 silently
|
|
94
|
+
// misses operator queries that match the new field.
|
|
95
|
+
"displayName", "slug", "role",
|
|
88
96
|
];
|
|
89
97
|
|
|
90
98
|
interface IndexDeclaration {
|
|
@@ -111,6 +111,26 @@
|
|
|
111
111
|
"OBSERVED_IN": {
|
|
112
112
|
"direction": "(*)-[:OBSERVED_IN]->(Conversation)",
|
|
113
113
|
"note": "Observation provenance."
|
|
114
|
+
},
|
|
115
|
+
"HAS_IDENTITY": {
|
|
116
|
+
"direction": "(Agent)-[:HAS_IDENTITY]->(KnowledgeDocument)",
|
|
117
|
+
"note": "Task 837 — public-agent IDENTITY.md projection (KnowledgeDocument with role='identity', namespaced attachmentId='agent:<slug>:identity')."
|
|
118
|
+
},
|
|
119
|
+
"HAS_SOUL": {
|
|
120
|
+
"direction": "(Agent)-[:HAS_SOUL]->(KnowledgeDocument)",
|
|
121
|
+
"note": "Task 837 — public-agent SOUL.md projection (role='soul')."
|
|
122
|
+
},
|
|
123
|
+
"HAS_KNOWLEDGE": {
|
|
124
|
+
"direction": "(Agent)-[:HAS_KNOWLEDGE]->(KnowledgeDocument)",
|
|
125
|
+
"note": "Task 837 — public-agent KNOWLEDGE.md projection (role='knowledge'); KNOWLEDGE-SUMMARY.md uses the same edge with role='knowledge-summary'."
|
|
126
|
+
},
|
|
127
|
+
"USES_KNOWLEDGE": {
|
|
128
|
+
"direction": "(Agent)-[:USES_KNOWLEDGE]->(KnowledgeDocument)",
|
|
129
|
+
"note": "Task 837 — operator-tagged docs (slug ∈ k.agents). Materialised at projection time only; runtime memory-search reads k.agents directly."
|
|
130
|
+
},
|
|
131
|
+
"HANDLED_BY": {
|
|
132
|
+
"direction": "(Conversation)-[:HANDLED_BY]->(Agent)",
|
|
133
|
+
"note": "Task 837 — public conversations only. Written by ensureConversation via OPTIONAL MATCH so orphan slugs do not block conversation creation."
|
|
114
134
|
}
|
|
115
135
|
},
|
|
116
136
|
"sublabels": {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 002 — Project file-based public agents into the graph (Task 837).
|
|
3
|
+
*
|
|
4
|
+
* Two passes:
|
|
5
|
+
*
|
|
6
|
+
* 1. Walk every account directory under data/accounts/<accountId>/agents/
|
|
7
|
+
* and call projectAgent(accountId, accountDir, slug) for each non-admin
|
|
8
|
+
* agent that has a config.json. The projector is idempotent: re-running
|
|
9
|
+
* this migration produces no duplicate nodes or edges.
|
|
10
|
+
*
|
|
11
|
+
* 2. For every existing public Conversation that carries an agentSlug
|
|
12
|
+
* property, MATCH the corresponding :Agent and MERGE the
|
|
13
|
+
* (:Conversation)-[:HANDLED_BY]->(:Agent) edge. Conversations whose
|
|
14
|
+
* slug doesn't resolve to an Agent (orphan slugs from deleted agents,
|
|
15
|
+
* or DM-channel public flows that haven't been extended to register a
|
|
16
|
+
* slug) are left edge-less; they will gain the edge automatically once
|
|
17
|
+
* the slug is registered or a new agent at that slug is projected.
|
|
18
|
+
*
|
|
19
|
+
* Run via the platform/ui standalone runtime so it picks up the same
|
|
20
|
+
* NEO4J_URI / accounts-directory resolution as the server:
|
|
21
|
+
*
|
|
22
|
+
* cd platform/ui && \
|
|
23
|
+
* NEO4J_URI=bolt://… NEO4J_PASSWORD=… \
|
|
24
|
+
* npx tsx ../neo4j/migrations/002-project-public-agents.ts
|
|
25
|
+
*
|
|
26
|
+
* Output: structured `[agent-graph-backfill]` lines per account + a final
|
|
27
|
+
* totals line. A non-zero exit code on any per-account failure surfaces to
|
|
28
|
+
* the operator; subsequent accounts are still attempted (the migration is
|
|
29
|
+
* isolating by account, not all-or-nothing).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import {
|
|
35
|
+
projectAgent,
|
|
36
|
+
getSession,
|
|
37
|
+
} from "../../ui/app/lib/neo4j-store";
|
|
38
|
+
import { ACCOUNTS_DIR } from "../../ui/app/lib/claude-agent/account";
|
|
39
|
+
|
|
40
|
+
interface PerAccountStats {
|
|
41
|
+
accountId: string;
|
|
42
|
+
agents: number;
|
|
43
|
+
agentFailures: number;
|
|
44
|
+
convEdges: number;
|
|
45
|
+
convOrphans: number;
|
|
46
|
+
convCandidates: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function projectAccountAgents(
|
|
50
|
+
accountId: string,
|
|
51
|
+
accountDir: string,
|
|
52
|
+
): Promise<{ agents: number; agentFailures: number }> {
|
|
53
|
+
const agentsDir = resolve(accountDir, "agents");
|
|
54
|
+
if (!existsSync(agentsDir)) return { agents: 0, agentFailures: 0 };
|
|
55
|
+
|
|
56
|
+
let agents = 0;
|
|
57
|
+
let agentFailures = 0;
|
|
58
|
+
for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
|
|
59
|
+
if (!entry.isDirectory()) continue;
|
|
60
|
+
if (entry.name === "admin") continue;
|
|
61
|
+
|
|
62
|
+
const configPath = resolve(agentsDir, entry.name, "config.json");
|
|
63
|
+
if (!existsSync(configPath)) continue;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await projectAgent(accountId, accountDir, entry.name);
|
|
67
|
+
agents++;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
agentFailures++;
|
|
70
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
71
|
+
console.error(
|
|
72
|
+
`[agent-graph-backfill] account=${accountId.slice(0, 8)} agent=${entry.name} project FAILED error="${msg}"`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { agents, agentFailures };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface HandledByBackfillStats {
|
|
80
|
+
candidates: number;
|
|
81
|
+
edges: number;
|
|
82
|
+
orphans: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Two-pass: count candidate Conversations (those carrying agentSlug),
|
|
87
|
+
* then OPTIONAL MATCH to surface orphans separately from successful merges.
|
|
88
|
+
* A hard MATCH-then-MATCH chain would silently filter orphan-slug rows out,
|
|
89
|
+
* making `edges=0` indistinguishable from "no public conversations exist"
|
|
90
|
+
* vs "every slug is orphaned" — different incidents, different fixes.
|
|
91
|
+
*/
|
|
92
|
+
async function backfillHandledByEdges(accountId: string): Promise<HandledByBackfillStats> {
|
|
93
|
+
const session = getSession();
|
|
94
|
+
try {
|
|
95
|
+
const result = await session.run(
|
|
96
|
+
`MATCH (c:Conversation {accountId: $accountId, agentType: 'public'})
|
|
97
|
+
WHERE c.agentSlug IS NOT NULL
|
|
98
|
+
OPTIONAL MATCH (a:Agent {accountId: $accountId, slug: c.agentSlug})
|
|
99
|
+
FOREACH (_ IN CASE WHEN a IS NULL THEN [] ELSE [1] END | MERGE (c)-[:HANDLED_BY]->(a))
|
|
100
|
+
RETURN
|
|
101
|
+
count(c) AS candidates,
|
|
102
|
+
sum(CASE WHEN a IS NULL THEN 0 ELSE 1 END) AS edges,
|
|
103
|
+
sum(CASE WHEN a IS NULL THEN 1 ELSE 0 END) AS orphans`,
|
|
104
|
+
{ accountId },
|
|
105
|
+
);
|
|
106
|
+
const toNum = (v: unknown): number => {
|
|
107
|
+
if (typeof v === "number") return v;
|
|
108
|
+
if (v && typeof (v as { toNumber: () => number }).toNumber === "function") {
|
|
109
|
+
return (v as { toNumber: () => number }).toNumber();
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
candidates: toNum(result.records[0]?.get("candidates")),
|
|
115
|
+
edges: toNum(result.records[0]?.get("edges")),
|
|
116
|
+
orphans: toNum(result.records[0]?.get("orphans")),
|
|
117
|
+
};
|
|
118
|
+
} finally {
|
|
119
|
+
await session.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main(): Promise<void> {
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
|
|
126
|
+
if (!existsSync(ACCOUNTS_DIR)) {
|
|
127
|
+
console.error(`[agent-graph-backfill] ACCOUNTS_DIR missing at ${ACCOUNTS_DIR} — nothing to do`);
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const accountEntries = readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
|
|
132
|
+
.filter((e) => e.isDirectory());
|
|
133
|
+
|
|
134
|
+
console.error(`[agent-graph-backfill] start accounts=${accountEntries.length}`);
|
|
135
|
+
|
|
136
|
+
let totalAgents = 0;
|
|
137
|
+
let totalAgentFailures = 0;
|
|
138
|
+
let totalConvEdges = 0;
|
|
139
|
+
let totalConvOrphans = 0;
|
|
140
|
+
let totalConvCandidates = 0;
|
|
141
|
+
const perAccount: PerAccountStats[] = [];
|
|
142
|
+
|
|
143
|
+
for (const entry of accountEntries) {
|
|
144
|
+
const accountDir = resolve(ACCOUNTS_DIR, entry.name);
|
|
145
|
+
const accountId = entry.name;
|
|
146
|
+
const accountStart = Date.now();
|
|
147
|
+
|
|
148
|
+
const { agents, agentFailures } = await projectAccountAgents(accountId, accountDir);
|
|
149
|
+
totalAgents += agents;
|
|
150
|
+
totalAgentFailures += agentFailures;
|
|
151
|
+
|
|
152
|
+
let convStats: HandledByBackfillStats = { candidates: 0, edges: 0, orphans: 0 };
|
|
153
|
+
try {
|
|
154
|
+
convStats = await backfillHandledByEdges(accountId);
|
|
155
|
+
totalConvEdges += convStats.edges;
|
|
156
|
+
totalConvOrphans += convStats.orphans;
|
|
157
|
+
totalConvCandidates += convStats.candidates;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
console.error(
|
|
161
|
+
`[agent-graph-backfill] account=${accountId.slice(0, 8)} handled-by-backfill FAILED error="${msg}"`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
perAccount.push({
|
|
166
|
+
accountId,
|
|
167
|
+
agents,
|
|
168
|
+
agentFailures,
|
|
169
|
+
convEdges: convStats.edges,
|
|
170
|
+
convOrphans: convStats.orphans,
|
|
171
|
+
convCandidates: convStats.candidates,
|
|
172
|
+
});
|
|
173
|
+
const ms = Date.now() - accountStart;
|
|
174
|
+
console.error(
|
|
175
|
+
`[agent-graph-backfill] account=${accountId.slice(0, 8)} agents=${agents} failures=${agentFailures} conv-candidates=${convStats.candidates} conv-edges=${convStats.edges} conv-orphans=${convStats.orphans} ms=${ms}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ms = Date.now() - start;
|
|
180
|
+
console.error(
|
|
181
|
+
`[agent-graph-backfill] done totals: agents=${totalAgents} agent-failures=${totalAgentFailures} conv-candidates=${totalConvCandidates} conv-edges=${totalConvEdges} conv-orphans=${totalConvOrphans} ms=${ms}`,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
process.exit(totalAgentFailures > 0 ? 1 : 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().catch((err) => {
|
|
188
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
189
|
+
console.error(`[agent-graph-backfill] fatal error="${msg}"`);
|
|
190
|
+
process.exit(2);
|
|
191
|
+
});
|
|
@@ -286,6 +286,7 @@ OPTIONS {
|
|
|
286
286
|
// - Email: Email, EmailAccount
|
|
287
287
|
// - Review signals: ReviewAlert
|
|
288
288
|
// - CV/career sublabels: Position, Credential
|
|
289
|
+
// - Public agents: Agent (Task 837 — projection of file-based public agents)
|
|
289
290
|
//
|
|
290
291
|
// Property union — every textual property the schema's writers assign:
|
|
291
292
|
// - Generic: name, title, summary, body, content, text, description, headline, abstract,
|
|
@@ -301,6 +302,11 @@ OPTIONS {
|
|
|
301
302
|
// - Credential: authority
|
|
302
303
|
// - AccessGrant: contactValue
|
|
303
304
|
// - ToolCall: toolName
|
|
305
|
+
// - Agent (Task 837): displayName, slug, role
|
|
306
|
+
// `displayName` = operator-facing name; `slug` = directory-name identifier;
|
|
307
|
+
// `role` = discriminator on KnowledgeDocument projections
|
|
308
|
+
// ('identity'|'soul'|'knowledge'|'knowledge-summary') so BM25 hits surface
|
|
309
|
+
// which file backed the result. Distinct from Person.role (no shadow).
|
|
304
310
|
CREATE FULLTEXT INDEX entity_search IF NOT EXISTS
|
|
305
311
|
FOR (n:LocalBusiness|Service|PriceSpecification|OpeningHoursSpecification|Organization
|
|
306
312
|
|Person|UserProfile|Preference|AdminUser|AccessGrant
|
|
@@ -309,12 +315,13 @@ FOR (n:LocalBusiness|Service|PriceSpecification|OpeningHoursSpecification|Organi
|
|
|
309
315
|
|Task|Project|Event
|
|
310
316
|
|Workflow|WorkflowStep|WorkflowRun|StepResult
|
|
311
317
|
|OnboardingState|Email|EmailAccount|ReviewAlert
|
|
312
|
-
|Position|Credential)
|
|
318
|
+
|Position|Credential|Agent)
|
|
313
319
|
ON EACH [n.name, n.firstName, n.lastName, n.givenName, n.familyName,
|
|
314
320
|
n.title, n.currentTitle, n.summary, n.body, n.content, n.text, n.description, n.headline, n.abstract,
|
|
315
321
|
n.email, n.note, n.label, n.value, n.message, n.preview, n.tagline,
|
|
316
322
|
n.subject, n.bodyPreview, n.fromName, n.fromAddress, n.agentAddress, n.screeningReason,
|
|
317
|
-
n.authority, n.contactValue, n.toolName
|
|
323
|
+
n.authority, n.contactValue, n.toolName,
|
|
324
|
+
n.displayName, n.slug, n.role];
|
|
318
325
|
|
|
319
326
|
// Project node (Task 740) — a standalone creative-output node distinct from
|
|
320
327
|
// :Section. Anchored via (:UserProfile)-[:CREATED]->(:Project), with optional
|
|
@@ -724,6 +731,66 @@ FOR (ag:AccessGrant) ON (ag.magicToken);
|
|
|
724
731
|
CREATE INDEX access_grant_status IF NOT EXISTS
|
|
725
732
|
FOR (ag:AccessGrant) ON (ag.status);
|
|
726
733
|
|
|
734
|
+
// ----------------------------------------------------------
|
|
735
|
+
// Agent node — projection of file-based public agents (Task 837)
|
|
736
|
+
//
|
|
737
|
+
// Source of truth remains the filesystem: accountDir/agents/<slug>/
|
|
738
|
+
// {config.json, IDENTITY.md, SOUL.md, KNOWLEDGE.md, KNOWLEDGE-SUMMARY.md}.
|
|
739
|
+
// The Agent node is a graph projection so operators can see, on /graph,
|
|
740
|
+
// which public agents exist, what knowledge they have access to, and
|
|
741
|
+
// which conversations they have handled. Re-running the projector
|
|
742
|
+
// is idempotent and never mutates the on-disk files.
|
|
743
|
+
//
|
|
744
|
+
// Properties (mirrored from config.json + filesystem mtime):
|
|
745
|
+
// slug — directory name; immutable identifier within an account
|
|
746
|
+
// displayName — operator-facing name from config.json
|
|
747
|
+
// status — 'active' | 'inactive' | <other> per config.json
|
|
748
|
+
// model — Anthropic model id used by the public agent
|
|
749
|
+
// liveMemory — bool; whether memory-search runs at message time
|
|
750
|
+
// knowledgeKeywords — string[] (live-search keyword subscriptions)
|
|
751
|
+
// role — always 'agent' (mirrors KnowledgeDocument.role
|
|
752
|
+
// discriminator; surfaces in BM25 hits)
|
|
753
|
+
// createdAt — ISO 8601, set on first projection
|
|
754
|
+
// updatedAt — ISO 8601, set on every projection
|
|
755
|
+
//
|
|
756
|
+
// Owned KnowledgeDocument projections (deleted on agent delete):
|
|
757
|
+
// (Agent)-[:HAS_IDENTITY]->(:KnowledgeDocument {role:'identity'})
|
|
758
|
+
// (Agent)-[:HAS_SOUL ]->(:KnowledgeDocument {role:'soul'})
|
|
759
|
+
// (Agent)-[:HAS_KNOWLEDGE]->(:KnowledgeDocument {role:'knowledge'})
|
|
760
|
+
// (Agent)-[:HAS_KNOWLEDGE]->(:KnowledgeDocument {role:'knowledge-summary'})
|
|
761
|
+
// attachmentId is namespaced as "agent:<accountId>:<slug>:<role>" so the
|
|
762
|
+
// projection reuses the existing knowledge_doc_id_unique constraint without
|
|
763
|
+
// a parallel uniqueness scheme. The accountId segment is load-bearing —
|
|
764
|
+
// without it, two accounts with the same agent slug would collide on the
|
|
765
|
+
// global unique constraint and the second projection would silently
|
|
766
|
+
// re-parent the first account's doc via the MERGE+SET path. Account
|
|
767
|
+
// isolation is doctrine.
|
|
768
|
+
//
|
|
769
|
+
// Operator-tagged docs (NOT deleted on agent delete — only edges removed):
|
|
770
|
+
// (Agent)-[:USES_KNOWLEDGE]->(:KnowledgeDocument)
|
|
771
|
+
// where slug ∈ KnowledgeDocument.agents. Materialised at projection time
|
|
772
|
+
// only; the runtime memory-search path continues to read k.agents directly,
|
|
773
|
+
// so a doc tagged after the last projection becomes graph-visible at the
|
|
774
|
+
// next re-projection but is reachable at runtime immediately. The runtime
|
|
775
|
+
// path is unchanged — see [public-agent.ts:130-160].
|
|
776
|
+
//
|
|
777
|
+
// Conversation→Agent edge (written by ensureConversation when
|
|
778
|
+
// agentType='public' and agentSlug is set):
|
|
779
|
+
// (Conversation)-[:HANDLED_BY]->(Agent)
|
|
780
|
+
// OPTIONAL MATCH on the Agent so a public conversation with an orphan
|
|
781
|
+
// slug still writes (single `[agent-graph] handled-by-skip` log line).
|
|
782
|
+
//
|
|
783
|
+
// Composite uniqueness: one Agent per (accountId, slug) — the directory
|
|
784
|
+
// name is the natural key within an account, slugs collide across
|
|
785
|
+
// accounts and that's allowed.
|
|
786
|
+
// ----------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
CREATE CONSTRAINT agent_account_slug_unique IF NOT EXISTS
|
|
789
|
+
FOR (a:Agent) REQUIRE (a.accountId, a.slug) IS UNIQUE;
|
|
790
|
+
|
|
791
|
+
CREATE INDEX agent_account IF NOT EXISTS
|
|
792
|
+
FOR (a:Agent) ON (a.accountId);
|
|
793
|
+
|
|
727
794
|
// ----------------------------------------------------------
|
|
728
795
|
// AdminUser node — device-level admin identity
|
|
729
796
|
// Platform-native. Represents a human who administers one or
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for archive-ingest-gate.sh (Task 846).
|
|
3
|
+
#
|
|
4
|
+
# Six cases cover the contract:
|
|
5
|
+
# 1. Edit on /platform/plugins/<x>/lib/* is BLOCKED (exit 2).
|
|
6
|
+
# 2. Edit on a benign path is ALLOWED (exit 0).
|
|
7
|
+
# 3. Bash with `npx vitest` is BLOCKED.
|
|
8
|
+
# 4. PostToolUse on whatsapp-export-parse with isError:true sets the flag.
|
|
9
|
+
# 5. Subsequent PreToolUse on ANY tool is BLOCKED (post-parse-error gate).
|
|
10
|
+
# 6. UserPromptSubmit clears the flag, restoring normal allow behavior.
|
|
11
|
+
#
|
|
12
|
+
# Tests use ARCHIVE_INGEST_GATE_STATE_DIR to point at a tmp dir so they run
|
|
13
|
+
# without a real account layout.
|
|
14
|
+
|
|
15
|
+
set -u
|
|
16
|
+
|
|
17
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/archive-ingest-gate.sh"
|
|
18
|
+
if [[ ! -x "$HOOK" ]]; then
|
|
19
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Per-run isolated state dir
|
|
24
|
+
STATE_DIR=$(mktemp -d)
|
|
25
|
+
export ARCHIVE_INGEST_GATE_STATE_DIR="$STATE_DIR"
|
|
26
|
+
FLAG_FILE="$STATE_DIR/archive-ingest-parse-error.flag"
|
|
27
|
+
|
|
28
|
+
cleanup() { rm -rf "$STATE_DIR"; }
|
|
29
|
+
trap cleanup EXIT
|
|
30
|
+
|
|
31
|
+
PASS=0
|
|
32
|
+
FAIL=0
|
|
33
|
+
|
|
34
|
+
run_case() {
|
|
35
|
+
local name="$1" stdin="$2" expected_exit="$3"
|
|
36
|
+
local actual_exit
|
|
37
|
+
printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
|
|
38
|
+
actual_exit=$?
|
|
39
|
+
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
|
|
40
|
+
echo "PASS: $name (exit=$actual_exit)"
|
|
41
|
+
PASS=$((PASS + 1))
|
|
42
|
+
else
|
|
43
|
+
echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
|
|
44
|
+
FAIL=$((FAIL + 1))
|
|
45
|
+
fi
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Case 1 — Edit on plugin lib path: BLOCKED
|
|
49
|
+
run_case "Edit on platform/plugins/whatsapp-import/lib/src/parse-export.ts → BLOCKED" \
|
|
50
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/platform/plugins/whatsapp-import/lib/src/parse-export.ts","old_string":"a","new_string":"b"}}' \
|
|
51
|
+
2
|
|
52
|
+
|
|
53
|
+
# Case 2 — Edit on a benign path: ALLOWED
|
|
54
|
+
run_case "Edit on README.md → ALLOWED" \
|
|
55
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/README.md","old_string":"a","new_string":"b"}}' \
|
|
56
|
+
0
|
|
57
|
+
|
|
58
|
+
# Case 3 — Bash with `npx vitest`: BLOCKED
|
|
59
|
+
run_case "Bash 'npx vitest run parse-export.test.ts' → BLOCKED" \
|
|
60
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npx vitest run parse-export.test.ts"}}' \
|
|
61
|
+
2
|
|
62
|
+
|
|
63
|
+
# Case 3b — Bash with benign command: ALLOWED
|
|
64
|
+
run_case "Bash 'ls -la' → ALLOWED" \
|
|
65
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
|
|
66
|
+
0
|
|
67
|
+
|
|
68
|
+
# Case 3c — Bash with `bun test`: BLOCKED
|
|
69
|
+
run_case "Bash 'bun test' → BLOCKED" \
|
|
70
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bun test"}}' \
|
|
71
|
+
2
|
|
72
|
+
|
|
73
|
+
# Case 3d — Bash with `npm test`: BLOCKED
|
|
74
|
+
run_case "Bash 'npm test' → BLOCKED" \
|
|
75
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"}}' \
|
|
76
|
+
2
|
|
77
|
+
|
|
78
|
+
# Make sure flag is absent before parse-error simulation
|
|
79
|
+
rm -f "$FLAG_FILE"
|
|
80
|
+
|
|
81
|
+
# Case 4 — PostToolUse on whatsapp-export-parse with isError:true sets flag
|
|
82
|
+
run_case "PostToolUse parse-error sets flag (exit 0, flag side-effect)" \
|
|
83
|
+
'{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":true,"content":[{"type":"text","text":"parse-error file=_chat.txt line=1 reason=not-a-_chat.txt"}]}}' \
|
|
84
|
+
0
|
|
85
|
+
|
|
86
|
+
if [[ -f "$FLAG_FILE" ]]; then
|
|
87
|
+
echo "PASS: parse-error flag created at $FLAG_FILE"
|
|
88
|
+
PASS=$((PASS + 1))
|
|
89
|
+
else
|
|
90
|
+
echo "FAIL: parse-error flag NOT created at $FLAG_FILE" >&2
|
|
91
|
+
FAIL=$((FAIL + 1))
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# Case 5 — Subsequent PreToolUse on ANY tool BLOCKED while flag is fresh
|
|
95
|
+
run_case "PreToolUse Read after parse-error → BLOCKED" \
|
|
96
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
|
|
97
|
+
2
|
|
98
|
+
|
|
99
|
+
run_case "PreToolUse Bash after parse-error → BLOCKED" \
|
|
100
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}' \
|
|
101
|
+
2
|
|
102
|
+
|
|
103
|
+
# Case 6 — UserPromptSubmit clears flag
|
|
104
|
+
run_case "UserPromptSubmit clears flag (exit 0)" \
|
|
105
|
+
'{"hook_event_name":"UserPromptSubmit","prompt":"retry"}' \
|
|
106
|
+
0
|
|
107
|
+
|
|
108
|
+
if [[ ! -f "$FLAG_FILE" ]]; then
|
|
109
|
+
echo "PASS: UserPromptSubmit cleared flag"
|
|
110
|
+
PASS=$((PASS + 1))
|
|
111
|
+
else
|
|
112
|
+
echo "FAIL: UserPromptSubmit did NOT clear flag" >&2
|
|
113
|
+
FAIL=$((FAIL + 1))
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# Case 7 — After clearance, normal allow resumes
|
|
117
|
+
run_case "PreToolUse Read after clearance → ALLOWED" \
|
|
118
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
|
|
119
|
+
0
|
|
120
|
+
|
|
121
|
+
# Case 8 — PostToolUse with isError:false does NOT set flag
|
|
122
|
+
rm -f "$FLAG_FILE"
|
|
123
|
+
run_case "PostToolUse parse-success (isError:false) does NOT set flag" \
|
|
124
|
+
'{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":false,"content":[{"type":"text","text":"{\"parsedLines\":[]}"}]}}' \
|
|
125
|
+
0
|
|
126
|
+
|
|
127
|
+
if [[ ! -f "$FLAG_FILE" ]]; then
|
|
128
|
+
echo "PASS: parse-success leaves flag absent"
|
|
129
|
+
PASS=$((PASS + 1))
|
|
130
|
+
else
|
|
131
|
+
echo "FAIL: parse-success incorrectly created flag" >&2
|
|
132
|
+
FAIL=$((FAIL + 1))
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
# Case 9 — Stale flag (>600s) auto-clears + allows
|
|
136
|
+
PAST=$(( $(date -u +%s) - 700 ))
|
|
137
|
+
echo "$PAST" > "$FLAG_FILE"
|
|
138
|
+
run_case "Stale flag auto-clears, PreToolUse Read → ALLOWED" \
|
|
139
|
+
'{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
|
|
140
|
+
0
|
|
141
|
+
|
|
142
|
+
# Case 10 — No stdin (terminal) fails closed
|
|
143
|
+
echo "Probing fail-closed behaviour (no stdin)..."
|
|
144
|
+
bash "$HOOK" </dev/null >/dev/null 2>/dev/null
|
|
145
|
+
ACTUAL=$?
|
|
146
|
+
# /dev/null IS a stdin — the `[ -t 0 ]` check tests for terminal, not file.
|
|
147
|
+
# A file/pipe stdin reads as empty, which produces empty hook_event_name and
|
|
148
|
+
# falls through to default `exit 0` (allow). The terminal-only fail-closed
|
|
149
|
+
# branch can't be tested non-interactively; verify the script reads `[ -t 0 ]`.
|
|
150
|
+
if grep -q '\[ -t 0 \]' "$HOOK"; then
|
|
151
|
+
echo "PASS: fail-closed terminal check is present"
|
|
152
|
+
PASS=$((PASS + 1))
|
|
153
|
+
else
|
|
154
|
+
echo "FAIL: fail-closed terminal check missing" >&2
|
|
155
|
+
FAIL=$((FAIL + 1))
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
echo
|
|
159
|
+
echo "──────── archive-ingest-gate test summary ────────"
|
|
160
|
+
echo "PASS: $PASS"
|
|
161
|
+
echo "FAIL: $FAIL"
|
|
162
|
+
|
|
163
|
+
if [[ "$FAIL" -gt 0 ]]; then
|
|
164
|
+
exit 1
|
|
165
|
+
fi
|
|
166
|
+
exit 0
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Archive-ingest gate (Task 846).
|
|
3
|
+
#
|
|
4
|
+
# Three enforcements, one script — phase decided by `hook_event_name` on stdin:
|
|
5
|
+
#
|
|
6
|
+
# 1. PreToolUse Edit/Write/NotebookEdit: deny writes under
|
|
7
|
+
# `*platform/plugins/*/lib/*` (parser/CSV-shape source for any *-import or
|
|
8
|
+
# *-export plugin). The database-operator subagent has Read+Bash but not
|
|
9
|
+
# Edit/Write per its `tools:` frontmatter — yet it can still mutate files
|
|
10
|
+
# via Bash heredoc. The path block applies to every tool that takes a
|
|
11
|
+
# `file_path` and has been observed as the improvisation surface.
|
|
12
|
+
#
|
|
13
|
+
# 2. PreToolUse Bash: deny commands invoking JavaScript test runners
|
|
14
|
+
# (vitest|bun test|npm test|npx jest|node .*vitest). The reproducer
|
|
15
|
+
# incident (conv 47c6a590) saw the operator run all four variants in
|
|
16
|
+
# sequence after parse-export returned isError.
|
|
17
|
+
#
|
|
18
|
+
# 3. Parse-error gate: PostToolUse on any `mcp__*__*-export-parse` /
|
|
19
|
+
# `mcp__*__*-import-parse` tool whose `tool_response.isError == true`
|
|
20
|
+
# writes a flag file. Subsequent PreToolUse on ANY tool blocks until
|
|
21
|
+
# UserPromptSubmit clears the flag (semantics: "subagent's next action
|
|
22
|
+
# must be a user-facing message; further tool calls blocked"). A 600s
|
|
23
|
+
# TTL is the cross-session safety net.
|
|
24
|
+
#
|
|
25
|
+
# Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
|
|
26
|
+
# message shown to the agent). Fail-closed on stdin read failure to match
|
|
27
|
+
# pre-tool-use.sh.
|
|
28
|
+
|
|
29
|
+
set -uo pipefail
|
|
30
|
+
|
|
31
|
+
# Read stdin — fail closed if unavailable
|
|
32
|
+
if [ -t 0 ]; then
|
|
33
|
+
echo "Blocked: archive-ingest-gate received no stdin (cannot inspect tool call). Failing closed." >&2
|
|
34
|
+
exit 2
|
|
35
|
+
fi
|
|
36
|
+
INPUT=$(cat)
|
|
37
|
+
|
|
38
|
+
# ----- Resolve account dir for state file ----------------------------------
|
|
39
|
+
# Mirrors pre-tool-use.sh lines 100-107: walk from this hook's location to
|
|
40
|
+
# platform/, then `../data/accounts/<single-account>/`. Phase 0 has exactly
|
|
41
|
+
# one account directory.
|
|
42
|
+
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
43
|
+
PLATFORM_ROOT_RESOLVED="${HOOK_DIR}/../../.."
|
|
44
|
+
ACCOUNTS_DIR="${PLATFORM_ROOT_RESOLVED}/../data/accounts"
|
|
45
|
+
ACCOUNT_DIR=""
|
|
46
|
+
if [ -d "$ACCOUNTS_DIR" ]; then
|
|
47
|
+
ACCOUNT_DIR=$(find "$ACCOUNTS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)
|
|
48
|
+
fi
|
|
49
|
+
# Test harness override — tests pass `ARCHIVE_INGEST_GATE_STATE_DIR` to point
|
|
50
|
+
# at a tmp dir without needing a real account layout.
|
|
51
|
+
STATE_DIR="${ARCHIVE_INGEST_GATE_STATE_DIR:-${ACCOUNT_DIR}/state}"
|
|
52
|
+
FLAG_FILE="${STATE_DIR}/archive-ingest-parse-error.flag"
|
|
53
|
+
TTL_SECONDS=600
|
|
54
|
+
|
|
55
|
+
# ----- Parse fields from stdin ---------------------------------------------
|
|
56
|
+
# Hook protocol: every event includes `hook_event_name`. PreToolUse +
|
|
57
|
+
# PostToolUse include `tool_name`. PostToolUse adds `tool_response`.
|
|
58
|
+
# UserPromptSubmit has neither. Use grep/sed against the JSON envelope —
|
|
59
|
+
# no jq dependency to match the rest of the hook fleet.
|
|
60
|
+
HOOK_EVENT=$(printf '%s' "$INPUT" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
61
|
+
TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
62
|
+
|
|
63
|
+
# ============================================================================
|
|
64
|
+
# UserPromptSubmit — clear the parse-error flag.
|
|
65
|
+
# ============================================================================
|
|
66
|
+
if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
|
|
67
|
+
if [ -f "$FLAG_FILE" ]; then
|
|
68
|
+
rm -f "$FLAG_FILE" 2>/dev/null
|
|
69
|
+
echo "[archive-ingest-gate] cleared reason=user-prompt-submit" >&2
|
|
70
|
+
fi
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# ============================================================================
|
|
75
|
+
# PostToolUse — record parse-error from any *-export-parse / *-import-parse.
|
|
76
|
+
# ============================================================================
|
|
77
|
+
if [ "$HOOK_EVENT" = "PostToolUse" ]; then
|
|
78
|
+
case "$TOOL_NAME" in
|
|
79
|
+
mcp__*__*-export-parse|mcp__*__*-import-parse)
|
|
80
|
+
# Inspect tool_response for isError true. Body is a JSON object; match
|
|
81
|
+
# `"isError":true` with optional whitespace.
|
|
82
|
+
if printf '%s' "$INPUT" | grep -Eq '"isError"[[:space:]]*:[[:space:]]*true'; then
|
|
83
|
+
mkdir -p "$STATE_DIR" 2>/dev/null
|
|
84
|
+
date -u +%s > "$FLAG_FILE" 2>/dev/null
|
|
85
|
+
echo "[archive-ingest-gate] surfaced reason=parse-error tool=${TOOL_NAME}" >&2
|
|
86
|
+
fi
|
|
87
|
+
;;
|
|
88
|
+
esac
|
|
89
|
+
# PostToolUse must always allow the tool result through.
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# ============================================================================
|
|
94
|
+
# PreToolUse — three independent blocks.
|
|
95
|
+
# ============================================================================
|
|
96
|
+
if [ "$HOOK_EVENT" != "PreToolUse" ]; then
|
|
97
|
+
# Unknown event, or hook fired in a context without an event name. Allow.
|
|
98
|
+
exit 0
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# --- Block 1: post-parse-error gate (applies to ALL tools) -----------------
|
|
102
|
+
if [ -f "$FLAG_FILE" ]; then
|
|
103
|
+
FLAG_TS=$(cat "$FLAG_FILE" 2>/dev/null | head -1)
|
|
104
|
+
NOW=$(date -u +%s)
|
|
105
|
+
if [ -n "$FLAG_TS" ] && [ "$FLAG_TS" -gt 0 ] 2>/dev/null; then
|
|
106
|
+
AGE=$(( NOW - FLAG_TS ))
|
|
107
|
+
if [ "$AGE" -lt "$TTL_SECONDS" ]; then
|
|
108
|
+
echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=post-parse-error age_s=${AGE}" >&2
|
|
109
|
+
echo "Blocked: an archive-parser MCP tool returned isError=true earlier in this turn. The subagent's next action must be a user-facing message naming the parse-error and yielding back to the operator. Further tool calls are blocked until the operator submits a new prompt." >&2
|
|
110
|
+
exit 2
|
|
111
|
+
fi
|
|
112
|
+
# Stale flag — clean up so we don't keep blocking on a corpse.
|
|
113
|
+
rm -f "$FLAG_FILE" 2>/dev/null
|
|
114
|
+
else
|
|
115
|
+
# Unparseable flag — remove rather than block on garbage.
|
|
116
|
+
rm -f "$FLAG_FILE" 2>/dev/null
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# --- Block 2: plugin-source path block (Edit/Write/NotebookEdit) -----------
|
|
121
|
+
case "$TOOL_NAME" in
|
|
122
|
+
Edit|Write|NotebookEdit)
|
|
123
|
+
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
124
|
+
case "$FILE_PATH" in
|
|
125
|
+
*/platform/plugins/*/lib/*|platform/plugins/*/lib/*)
|
|
126
|
+
echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=plugin-source-edit path=${FILE_PATH}" >&2
|
|
127
|
+
echo "Blocked: ${TOOL_NAME} on ${FILE_PATH} is a platform plugin lib/ path. The database-operator subagent does not own plugin source; if a parser is broken, surface the parse-error to the operator and let them dispatch a code-edit task instead." >&2
|
|
128
|
+
exit 2
|
|
129
|
+
;;
|
|
130
|
+
esac
|
|
131
|
+
;;
|
|
132
|
+
esac
|
|
133
|
+
|
|
134
|
+
# --- Block 3: shell test-runner block (Bash) -------------------------------
|
|
135
|
+
if [ "$TOOL_NAME" = "Bash" ]; then
|
|
136
|
+
COMMAND=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
137
|
+
# Match any of: `vitest`, `bun test`, `npm test`, `npx jest`, `node ... vitest`.
|
|
138
|
+
# Word-boundary checks via grep -E with explicit token boundaries — POSIX
|
|
139
|
+
# `[[:space:]]` covers leading-token edge cases, end-of-string covers tail.
|
|
140
|
+
if printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:]/])vitest($|[[:space:]])|(^|[[:space:]])bun[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npm[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npx[[:space:]]+jest($|[[:space:]])|node[[:space:]].*vitest'; then
|
|
141
|
+
echo "[archive-ingest-gate] block tool=Bash reason=test-runner command=${COMMAND}" >&2
|
|
142
|
+
echo "Blocked: Bash command invokes a JavaScript test runner (vitest/bun test/npm test/npx jest). The database-operator subagent does not run plugin tests; surface the parse-error to the operator." >&2
|
|
143
|
+
exit 2
|
|
144
|
+
fi
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
exit 0
|